fix(doc): mention 配对增加双射一致性校验

评审连续三轮卡在「目标互换仍返回 verified=true」。逐目标核验需要
openDingTalkId → staffId 的反查,而该反查在 CLI 面上不存在(openDingTalkId 仅作
输出字段;contact 查询入口是姓名/手机/部门/角色),dws api 又要求自有应用凭证且
把 raw OpenAPI 塞进文档校验路径违背现有架构——故无法在本 PR 内实现。

但按位置配对之外还有两条纯本地可判的约束此前没做,现补上:
- 同一个 openDingTalkId 必须解析到同一个 profile 目标
- 两个不同的 openDingTalkId 不得解析到同一个 profile 目标

这能捕捉服务端把身份搞混(同 id 前后不一致、不同 id 撞到同一人)的情形,把不可
判范围收窄到「两个不同 id 的目标互换」这一项——作者侧从不携带解析后的 staffId,
(A→X,B→Y) 与 (A→Y,B→X) 在本地信息量下不可区分,属信息不足而非实现薄弱。

验证:新增 4 行表驱动用例覆盖两条约束的正反面,另测 destination 提取的边界;
变更行覆盖率 100%;internal/shortcut/... 与 internal/helpers/... 全绿。
This commit is contained in:
nitonitori
2026-09-07 04:07:41 +08:00
parent 02c821578e
commit 7ef8f38768
2 changed files with 93 additions and 0 deletions
@@ -1281,13 +1281,37 @@ func isProfileLinkToken(token string) bool {
return strings.HasPrefix(token, docFingerprintLinkTokenPrefix+docProfileLinkPrefix)
}
// docFingerprintLinkDestination extracts the link destination from a fingerprint
// token shaped "open\x00link:<destination>\x00<title>".
func docFingerprintLinkDestination(token string) string {
rest := strings.TrimPrefix(token, docFingerprintLinkTokenPrefix)
if idx := strings.IndexByte(rest, 0); idx >= 0 {
return rest[:idx]
}
return rest
}
// markdownMentionAwareTokensEqual compares two fingerprint token sequences,
// tolerating exactly one kind of difference: an authored mention protocol link
// may appear as a profile link in the readback.
//
// Pairs are additionally required to form a consistent bijection: one
// openDingTalkId must resolve to one profile target throughout the document, and
// two different openDingTalkIds must not resolve to the same target. Both checks
// are decidable locally and catch a service that mixed identities up.
//
// What remains undetectable is a permutation of two *distinct* targets: the
// authored side never carries the resolved staffId, so (A→X, B→Y) and
// (A→Y, B→X) are indistinguishable here. That is missing information, not a
// weaker implementation — closing it needs the service to report what it
// rewrote, or a reverse openDingTalkId lookup that the CLI surface does not
// expose.
func markdownMentionAwareTokensEqual(actual, expected []string) bool {
if len(actual) != len(expected) {
return false
}
resolved := map[string]string{}
claimed := map[string]string{}
for index := range expected {
if actual[index] == expected[index] {
continue
@@ -1295,6 +1319,16 @@ func markdownMentionAwareTokensEqual(actual, expected []string) bool {
if !isMentionProtocolLinkToken(expected[index]) || !isProfileLinkToken(actual[index]) {
return false
}
mention := docFingerprintLinkDestination(expected[index])
target := docFingerprintLinkDestination(actual[index])
if previous, seen := resolved[mention]; seen && previous != target {
return false
}
if previous, seen := claimed[target]; seen && previous != mention {
return false
}
resolved[mention] = target
claimed[target] = mention
}
return true
}
@@ -853,3 +853,62 @@ func TestCrossPlatformCoverageDocMentionSuffixComparisonLayers(t *testing.T) {
t.Fatal("an oversized readback must not be accepted")
}
}
// Mention pairing must form a consistent bijection. Neither check needs an
// identity lookup, so both are enforced locally; a permutation of two distinct
// targets stays undetectable because the authored side carries no staffId.
func TestCrossPlatformCoverageDocMentionPairingRequiresBijection(t *testing.T) {
const (
mentionA = "alidocs-mcp://doc/mention?openDingTalkId=DEXAMPLEAAAA"
mentionB = "alidocs-mcp://doc/mention?openDingTalkId=DEXAMPLEBBBB"
profile1 = "dingtalk://dingtalkclient/page/profile?corp_id=dingexamplecorpid&staff_id=100001"
profile2 = "dingtalk://dingtalkclient/page/profile?corp_id=dingexamplecorpid&staff_id=100002"
)
for _, tc := range []struct {
name string
expected string
actual string
want bool
}{
{
"one id resolving to one target twice is accepted",
"[@甲](" + mentionA + ") 与 [@甲](" + mentionA + ")",
"[@甲](" + profile1 + ") 与 [@甲](" + profile1 + ")",
true,
},
{
"the same id resolving to two different targets is rejected",
"[@甲](" + mentionA + ") 与 [@甲](" + mentionA + ")",
"[@甲](" + profile1 + ") 与 [@甲](" + profile2 + ")",
false,
},
{
"two different ids collapsing onto one target is rejected",
"[@甲](" + mentionA + ") 与 [@乙](" + mentionB + ")",
"[@甲](" + profile1 + ") 与 [@乙](" + profile1 + ")",
false,
},
{
"two different ids resolving to two different targets is accepted",
"[@甲](" + mentionA + ") 与 [@乙](" + mentionB + ")",
"[@甲](" + profile1 + ") 与 [@乙](" + profile2 + ")",
true,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := verifyUpdatedDocumentContent(
map[string]any{"markdown": tc.actual}, tc.expected, "overwrite", "markdown")
if got != tc.want {
t.Fatalf("verify = %v, want %v\nexpected=%q\nactual=%q", got, tc.want, tc.expected, tc.actual)
}
})
}
if got := docFingerprintLinkDestination(docFingerprintLinkTokenPrefix + profile1 + "\x00"); got != profile1 {
t.Fatalf("destination extraction = %q", got)
}
if got := docFingerprintLinkDestination("open\x00link:bare"); got != "bare" {
t.Fatalf("a token without a title separator must still yield its destination: %q", got)
}
}