voc-forward-403-决策报告.md 9.7 KB

VOC Forward 403 权限拒绝 · 决策报告

时间: 2026-04-17 发现人: 刚sir(通过 apig-pay 端到端测试) 影响: 所有通过 apig-pay 充值的用户调用 /api/voc-ecom/forward 与 /api/voc-social/forward 时被返回 403,付费后仍无法使用接口 建议决策人: 陈经理


TL;DR

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权限或余额不足"}

对照实验(排除"余额不足"嫌疑)

  • 同一 token,调用 /api/voc-social/forward(余额 4182 次)
  • 返回:{"code":403,"mess":"没有开通社交平台API权限或余额不足"}
  • 结论:余额充足也 403,核心问题不在余额。

9 种 auth 变体探测

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 的实际写入

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,不满足后端契约。

另一类不匹配:历史数据 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 刷新 一次性执行,无部署

方案 A(后端 OR 查询)详细补丁

// 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;  // 总是传

方案 B(apig-pay 前端)详细补丁

// 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 查询)

理由:

  1. 语义最正确。APIGAuth 本身允许 company 为空(Schema 未强约束),查询方却强制二选一,这是后端自身的逻辑漏洞。修后端让两套绑定方式都能命中才是治本。

  2. 一处修复,全链路受益。任何走这套 forward 的前端(apig-pay、skill-executor、未来集成方)无需重复适配。

  3. 不需要数据迁移。存量 2 类问题记录(user-only、company-错位)自动被 OR 查询覆盖到,零风险。

  4. 改动局部。仅 getApiAuth + 调用处共 4 处小改动,语义可读性更高,不改变对外行为(成功路径仍走 company 优先)。

次选:方案 A + B 并行

若担心后端改动评审周期长,先上 B 让新用户不再踩坑;A 正常排期跟进。二者不冲突。

不推荐:纯方案 C。数据迁移一次只能救一批,未来新问题还会发生,治标不治本。


五、附录

E2E 证据(已执行)

━━━ 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 传参)

本次 Bug 影响的用户范围估算

需 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");

本次工作已完成部分

  • ✅ 27 个 skill 的 apigId 映射修复(社媒→电商)
  • ✅ apig-pay 多 APIG 切换器 UI 上线
  • ✅ 用户首次登录自动创建 count=0 APIGAuth 的机制跑通
  • ✅ 支付→回调→APIGAuth.count+=N 链路验证通过
  • 🔴 E2E 扣费闭环被本 Bug 阻塞

请陈经理决策

  1. 方案 A 后端修改是否批准?批准后由谁排期?
  2. 若暂不动后端,是否先走方案 B 让新用户不再卡壳?
  3. Rsx6t50Yf3(4182 次余额,company 历史错位)如何处理——合并到当前公司、作废重发、或保留待迁移?