时间: 2026-04-17
发现人: 刚sir(通过 apig-pay 端到端测试)
影响: 所有通过 apig-pay 充值的用户调用 /api/voc-ecom/forward 与 /api/voc-social/forward 时被返回 403,付费后仍无法使用接口
建议决策人: 陈经理
apig-pay 前端按"用户维度"写 APIGAuth 记录(只含 user 指针),后端 forward 优先按 "company 维度"查(APIGAuth.company == user.company),两侧口径不一致 → 查不到记录 → 返回 403 "没有开通电商API权限或余额不足"(尽管实际上余额充足)。
现有真实数据证据:
| APIGAuth | user | company (记录上) | user.company (用户当前) | count | 实际调用结果 |
|---|---|---|---|---|---|
czllJEwkCs (电商) |
2luFP8J1H1 | 缺失 | aOFIC3HGT4 | 1 | 403 |
Rsx6t50Yf3 (社媒) |
2luFP8J1H1 | 1b54XuxGlR(历史) | aOFIC3HGT4 | 4182 | 403 |
余额 1 次和 4182 次都打不通,证明与余额无关,纯粹是绑定字段不匹配。
curl -X POST https://server.fmode.cn/api/voc-ecom/forward \
-H "Content-Type: application/json" \
-H "Authorization: Bearer r:<sessionToken>" \
-d '{"path":"/api/KeywordQuery","method":"POST","query":{"domain":1},"body":{"Keyword":"test"}}'
{"code":403,"message":"没有开通电商API权限或余额不足"}
/api/voc-social/forward(余额 4182 次){"code":403,"mess":"没有开通社交平台API权限或余额不足"}Authorization: Bearer <session> 唯一能通过认证层(否则 401)。通过后一律被权限层拦截 → 403。
modules/voc-ecom-api/src/routes.ts
// 行 107-123
async function getApiAuth(company: any, user?: any): Promise<any | null> {
const query = new AuthParse.Query("APIGAuth");
if (company) {
query.equalTo('company', company); // ← 只查 company
} else {
query.equalTo('user', user); // ← 只查 user
}
query.equalTo("api", "7HwdQZk55B");
return await query.first({ useMasterKey: true });
}
// 行 225-227
const companyId = req.body?.company || authData?.company?.id || authData?.user?.company;
const userId = !companyId ? (authData?.user?.objectId || authData?.user?.id) : undefined;
// ^^^^^^^^^^^^^ 只要用户有 company,就永远不会用 user 回退
modules/voc-social-api/src/routes.ts 行 55-74、行 217-218 有完全相同的逻辑。
"APIGAuth.company 必须等于当前登录用户 user.company 指针目标"
apig-pay/src/app/app.ts:343-354 创建 APIGAuth 时:
const body = {
api: { __type: 'Pointer', className: 'APIG', objectId: apig },
user: { __type: 'Pointer', className: '_User', objectId: user },
count: 0,
used: 0
};
// 注意:没有写 company 字段
→ 所有新建 APIGAuth 缺 company,不满足后端契约。
用户 2luFP8J1H1 的 user.company = aOFIC3HGT4(当前归属),但历史 APIGAuth Rsx6t50Yf3.company = 1b54XuxGlR(早期公司)。即使 APIGAuth 有 company,如果不等于用户当前 company,后端一样查不到。
| 维度 | 方案 A:后端 OR 查询 | 方案 B:apig-pay 前端补 company | 方案 C:纯数据迁移 |
|---|---|---|---|
| 改动位置 | voc-ecom-api + voc-social-api 各 2 处 |
apig-pay/src/app/app.ts 1 处 + 存量自愈逻辑 |
一次性脚本 + 数据库 UPDATE |
| 改动行数 | ~20 行 TS | ~25 行 TS | ~50 行脚本 |
| 风险面 | 公司核心后端,影响所有 VOC 计费流程,回归测试面广 | 仅影响 apig-pay,独立前端工程 | 需 MasterKey,误操作影响全用户 |
| 长效性 | ✅ 一次修复永久生效,未来任何"user-only"记录都能命中 | ⚠️ 只覆盖新记录 + 自己触达的存量(用户需登录 apig-pay 触发自愈) | ⚠️ 只能修历史一批,未来仍需靠前端补或后端改 |
| 是否需要数据迁移 | ❌ 不需要 | ⚠️ 需要,但可在用户登录时渐进自愈 | ✅ 一次完成 |
对 Rsx6t50Yf3(company 错位)能否救回 |
✅ OR 查询直接命中 user=2luFP8J1H1 |
❌ 无法,除非强制覆盖 company(风险大) | ⚠️ 需人工确认是否合并老公司余额 |
| 可回滚性 | 改回单条查询即可 | 前端版本回退 | 最难回滚(数据一旦覆写) |
| 工作量(人天) | 0.5(含测试) | 0.5 | 0.5(+人工审核数据) |
| 部署链路 | fmode-server 重启 | apig-pay 构建 + OBS/CDN 刷新 | 一次性执行,无部署 |
// voc-ecom-api/src/routes.ts & voc-social-api/src/routes.ts
async function getApiAuth(company: any, user?: any): Promise<any | null> {
if (!company && !user) return null;
const queries: any[] = [];
if (company) {
const q = new AuthParse.Query("APIGAuth");
q.equalTo('company', company);
q.equalTo("api", ECOM_APIG_ID); // 或 SOCIAL_APIG_ID
queries.push(q);
}
if (user) {
const q = new AuthParse.Query("APIGAuth");
q.equalTo('user', user);
q.equalTo("api", ECOM_APIG_ID);
queries.push(q);
}
const combined = queries.length === 1
? queries[0]
: AuthParse.Query.or(...queries);
return await combined.first({ useMasterKey: true });
}
// 调用处去掉二选一
const companyId = req.body?.company || authData?.company?.id || authData?.user?.company;
const userId = authData?.user?.objectId || authData?.user?.id; // 总是传
// apig-pay/src/app/app.ts resolveAuthId
const body = {
api: { __type: 'Pointer', className: 'APIG', objectId: apig },
user: { __type: 'Pointer', className: '_User', objectId: user },
count: 0, used: 0
};
if (this.companyId) {
body.company = { __type: 'Pointer', className: 'Company', objectId: this.companyId };
}
// 同时:读到已有 APIGAuth 但缺 company 时自愈(PUT 补上)
if (found && !found.company && this.companyId) {
await fetch(API_BASE + '/parse/classes/APIGAuth/' + found.objectId, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
company: { __type: 'Pointer', className: 'Company', objectId: this.companyId }
})
});
}
首选:方案 A(后端 OR 查询)
理由:
语义最正确。APIGAuth 本身允许 company 为空(Schema 未强约束),查询方却强制二选一,这是后端自身的逻辑漏洞。修后端让两套绑定方式都能命中才是治本。
一处修复,全链路受益。任何走这套 forward 的前端(apig-pay、skill-executor、未来集成方)无需重复适配。
不需要数据迁移。存量 2 类问题记录(user-only、company-错位)自动被 OR 查询覆盖到,零风险。
改动局部。仅 getApiAuth + 调用处共 4 处小改动,语义可读性更高,不改变对外行为(成功路径仍走 company 优先)。
次选:方案 A + B 并行
若担心后端改动评审周期长,先上 B 让新用户不再踩坑;A 正常排期跟进。二者不冲突。
不推荐:纯方案 C。数据迁移一次只能救一批,未来新问题还会发生,治标不治本。
━━━ BEFORE ━━━
APIGAuth czllJEwkCs count: 1 used: 0
━━━ CALL voc/keyword-search ━━━
HTTP: 403 latency: 101ms
body: {"code":403,"message":"没有开通电商API权限或余额不足"}
━━━ AFTER (after 2s settle) ━━━
APIGAuth czllJEwkCs count: 1 used: 0
━━━ DELTA ━━━
count: 1 → 1 (Δ=0) ← 扣费没发生,请求被权限层拦下
| 类别 | 路径 |
|---|---|
| 后端 ecom 路由 | E:\workspace\server\future-server\fmode-server\modules\voc-ecom-api\src\routes.ts(行 107、225) |
| 后端 social 路由 | E:\workspace\server\future-server\fmode-server\modules\voc-social-api\src\routes.ts(行 55、217) |
| apig-pay 创建逻辑 | E:\workspace\apig-pay\src\app\app.ts(行 343-354 resolveAuthId) |
| apig-pay 订单逻辑 | E:\workspace\apig-pay\src\app\app.ts(行 482-483 createOrder 的 fcompany 传参) |
需 DBA 执行以下查询评估:
-- 1. 缺 company 的 APIGAuth 数量(方案 B 需要自愈的记录数)
SELECT COUNT(*) FROM "APIGAuth" WHERE "_p_company" IS NULL AND "_p_user" IS NOT NULL;
-- 2. company 与 user 所属 company 不一致的记录数(方案 A 才能救回的记录数)
SELECT a."objectId", a."_p_company", u."_p_company" AS user_company
FROM "APIGAuth" a
JOIN "_User" u ON u."objectId" = SUBSTRING(a."_p_user" FROM 7)
WHERE a."_p_company" IS NOT NULL
AND a."_p_company" <> CONCAT('Company$', u."_p_company");
count=0 APIGAuth 的机制跑通APIGAuth.count+=N 链路验证通过Rsx6t50Yf3(4182 次余额,company 历史错位)如何处理——合并到当前公司、作废重发、或保留待迁移?