问题描述
Web 端「全局编辑弹窗」(frontend/apps/web/src/components/GlobalEditDialogs.tsx) 没有共享账本分支:Editor 编辑共享账本交易时,标签候选列出的是 Editor 自己的个人标签,而不是 Owner 的共享标签。交易页主路径 (TransactionsPage.tsx) 是共享感知的,两条路径行为不一致。
叠加「该弹窗只提交标签名、不提交 tag_ids」,会复现 TNT-Likely/BeeCount#435 的重复标签链。
这是 #435 重复标签的主要产生路径,详见下面「为什么这条是承重的」。
三个具体点
1. 候选集是调用者自己的标签
弹窗通过 fetchWorkspaceTags(token, { ledgerId, limit: 500 }) 取候选。但服务端 src/routers/read/workspace.py::list_workspace_tags 的标签集是:
tag_query = select(UserTagProjection).where(
UserTagProjection.user_id == target_user_id # 非 admin 时 = current_user.id
)
ledger_id 只参与统计聚合,不影响返回哪些标签。所以共享账本下 Editor 拿到的是自己的个人标签。
2. 该弹窗完全不发 tag_ids
GlobalEditDialogs.tsx 的 payload 只有:
tags: editTxForm.tags.filter((s) => s.length > 0),
全文件 grep 无 tag_ids。对比 TransactionsPage.tsx 会先按名字反查候选集再发 tag_ids:
const txTagIds = txForm.tags
.map((value) => tagByName.get(value.trim().toLowerCase()))
.filter((value): value is string => Boolean(value))
3. Editor 确实能用到这个弹窗
GlobalEditDialogs.tsx 里 writableLedgers 的过滤条件是 (l) => l.role === 'owner' || l.role === 'editor'。
对比:主路径已经做对了
TransactionsPage.tsx:
const txWriteTags =
txIsSharedEditor && sharedBundle ? sharedAsRead.tags : txDictionaryTags
共享账本 Editor 走 shared bundle 的 Owner 标签。GlobalEditDialogs 缺这个分支。
为什么这条是承重的
App 侧 _syncTransactionTags 的建标签行为是分支决定的:
tagIds 非空 → name fallback 只复用已有本地标签,匹配不到就丢弃关联,从不创建
tagIds 完全缺失(name-only)→ 唯一的建标签点,按名字查不到就用新 UUID 建一条
而 App 的 push 只要有标签就一定带 tagIds(App 建的标签都有 syncId)。所以:
重复标签只会由 name-only payload 触发,而 name-only payload 只可能来自 Web / 服务端侧,不会来自 App。
产生 name-only payload 的路径:
| 来源 |
状态 |
Web GlobalEditDialogs |
本 issue |
Web AI 批量 src/routers/write/transactions_batch.py |
只收 tags: list[str],且服务端会 snapshot_mutator.create_tag 自行建实体 |
| CSV 导入 |
同类,未按 Owner 标签解析 |
| App push |
✅ 不产生 |
因此 App 侧屏蔽创建入口(TNT-Likely/BeeCount#436)解决不了这条 —— 那个 PR 只改 App 的标签选择器,而 Editor 即使不能在 App 里新建标签,仍然持有通过「标签管理页」创建的个人标签(方案1 明确保留这个能力),照样能在这个 Web 弹窗里挂到共享交易上。
影响链
Editor 用全局弹窗给一笔共享交易挂上自己的个人标签后:
- payload 是 name-only(有
tags、无 tag_ids)
- Owner 的 App 拉到这笔交易,按名字在本地
tags 表查不到(那是 Editor 的标签)
- 走唯一的建标签分支,用新
syncId 创建一条本地标签
- 该标签作为 Owner 的 user-global 标签上传
- 服务端出现两条同名、不同
syncId 的标签,分属 Editor 和 Owner
即 #435 的第 3~5 步。不依赖历史脏数据,是任何 Editor 今天就能触发的操作。
建议
GlobalEditDialogs 补共享账本分支,跟 TransactionsPage 对齐:txIsSharedEditor && sharedBundle 时用 sharedAsRead.tags。
- 同时补发
tag_ids(按名字反查候选集),别只发名字 —— 让服务端拿到权威 ID,不依赖名字匹配。
- 可选:
/workspace/tags 在传了 ledger_id 且该账本为共享账本时,是否应返回 Owner 的标签集而非调用者的。这样能从源头消除此类不一致,但影响面更大,需评估其它调用方。
AI 批量与 CSV 导入那两条路径会让服务端「间接为 Owner 建标签」,严重性低于本 issue(只建一次、在 Owner 名下,不产生重复 syncId),可另行评估是否处理。
优先级
高。这是 #435 在修掉 App 侧入口之后仍然存在的产生路径,且改动很小(一个分支 + 补发 tag_ids)。
问题描述
Web 端「全局编辑弹窗」(
frontend/apps/web/src/components/GlobalEditDialogs.tsx) 没有共享账本分支:Editor 编辑共享账本交易时,标签候选列出的是 Editor 自己的个人标签,而不是 Owner 的共享标签。交易页主路径 (TransactionsPage.tsx) 是共享感知的,两条路径行为不一致。叠加「该弹窗只提交标签名、不提交
tag_ids」,会复现 TNT-Likely/BeeCount#435 的重复标签链。这是 #435 重复标签的主要产生路径,详见下面「为什么这条是承重的」。
三个具体点
1. 候选集是调用者自己的标签
弹窗通过
fetchWorkspaceTags(token, { ledgerId, limit: 500 })取候选。但服务端src/routers/read/workspace.py::list_workspace_tags的标签集是:ledger_id只参与统计聚合,不影响返回哪些标签。所以共享账本下 Editor 拿到的是自己的个人标签。2. 该弹窗完全不发
tag_idsGlobalEditDialogs.tsx的 payload 只有:全文件 grep 无
tag_ids。对比TransactionsPage.tsx会先按名字反查候选集再发tag_ids:3. Editor 确实能用到这个弹窗
GlobalEditDialogs.tsx里writableLedgers的过滤条件是(l) => l.role === 'owner' || l.role === 'editor'。对比:主路径已经做对了
TransactionsPage.tsx:共享账本 Editor 走 shared bundle 的 Owner 标签。
GlobalEditDialogs缺这个分支。为什么这条是承重的
App 侧
_syncTransactionTags的建标签行为是分支决定的:tagIds非空 → name fallback 只复用已有本地标签,匹配不到就丢弃关联,从不创建tagIds完全缺失(name-only)→ 唯一的建标签点,按名字查不到就用新 UUID 建一条而 App 的 push 只要有标签就一定带
tagIds(App 建的标签都有 syncId)。所以:重复标签只会由 name-only payload 触发,而 name-only payload 只可能来自 Web / 服务端侧,不会来自 App。
产生 name-only payload 的路径:
GlobalEditDialogssrc/routers/write/transactions_batch.pytags: list[str],且服务端会snapshot_mutator.create_tag自行建实体因此 App 侧屏蔽创建入口(TNT-Likely/BeeCount#436)解决不了这条 —— 那个 PR 只改 App 的标签选择器,而 Editor 即使不能在 App 里新建标签,仍然持有通过「标签管理页」创建的个人标签(方案1 明确保留这个能力),照样能在这个 Web 弹窗里挂到共享交易上。
影响链
Editor 用全局弹窗给一笔共享交易挂上自己的个人标签后:
tags、无tag_ids)tags表查不到(那是 Editor 的标签)syncId创建一条本地标签syncId的标签,分属 Editor 和 Owner即 #435 的第 3~5 步。不依赖历史脏数据,是任何 Editor 今天就能触发的操作。
建议
GlobalEditDialogs补共享账本分支,跟TransactionsPage对齐:txIsSharedEditor && sharedBundle时用sharedAsRead.tags。tag_ids(按名字反查候选集),别只发名字 —— 让服务端拿到权威 ID,不依赖名字匹配。/workspace/tags在传了ledger_id且该账本为共享账本时,是否应返回 Owner 的标签集而非调用者的。这样能从源头消除此类不一致,但影响面更大,需评估其它调用方。AI 批量与 CSV 导入那两条路径会让服务端「间接为 Owner 建标签」,严重性低于本 issue(只建一次、在 Owner 名下,不产生重复
syncId),可另行评估是否处理。优先级
高。这是 #435 在修掉 App 侧入口之后仍然存在的产生路径,且改动很小(一个分支 + 补发
tag_ids)。