فهرست منبع

fix(voc): keep media assets local-only

gangvy 2 هفته پیش
والد
کامیت
2b3fca5e62
70فایلهای تغییر یافته به همراه5724 افزوده شده و 719 حذف شده
  1. 1 1
      claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json
  2. 5 11
      claude-code/claude-code-voc-intelligence/README.md
  3. 25 0
      claude-code/claude-code-voc-intelligence/SKILL.md
  4. 6 1
      claude-code/claude-code-voc-intelligence/bin/claude-voc.js
  5. 2 0
      claude-code/claude-code-voc-intelligence/docs/capability-map.md
  6. 1 1
      claude-code/claude-code-voc-intelligence/docs/live-manual-acceptance-checklist.md
  7. 4 4
      claude-code/claude-code-voc-intelligence/docs/payment-package-links.md
  8. 96 0
      claude-code/claude-code-voc-intelligence/docs/reports/three-account-platform-cost-2026-09-01.md
  9. 328 0
      claude-code/claude-code-voc-intelligence/docs/reports/three-account-user-billing-2026-09-01.html
  10. 10 0
      claude-code/claude-code-voc-intelligence/docs/reports/three-account-voc-usage-2026-09-01.md
  11. 156 0
      claude-code/claude-code-voc-intelligence/docs/taobao-item-detail-v7.md
  12. 1936 0
      claude-code/claude-code-voc-intelligence/docs/voc-cost-metering-and-balance-implementation-spec.md
  13. 1 0
      claude-code/claude-code-voc-intelligence/install.js
  14. 9 0
      claude-code/claude-code-voc-intelligence/mcp/catalog/params/taobao.json
  15. 46 0
      claude-code/claude-code-voc-intelligence/mcp/catalog/voc-social-endpoints.json
  16. 7 34
      claude-code/claude-code-voc-intelligence/mcp/src/core/activation.js
  17. 104 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/batch-budget-guard.js
  18. 173 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/billing-client.js
  19. 115 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/budget-guard.js
  20. 80 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js
  21. 72 138
      claude-code/claude-code-voc-intelligence/mcp/src/core/payment-links.js
  22. 81 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/recharge-client.js
  23. 11 4
      claude-code/claude-code-voc-intelligence/mcp/src/core/result-envelope.js
  24. 98 0
      claude-code/claude-code-voc-intelligence/mcp/src/core/usage-context.js
  25. 35 6
      claude-code/claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector.js
  26. 31 33
      claude-code/claude-code-voc-intelligence/mcp/src/features/fmode-image-analysis/image-analysis.js
  27. 42 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-discovery.js
  28. 52 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-manifest.js
  29. 90 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-pipeline.js
  30. 90 3
      claude-code/claude-code-voc-intelligence/mcp/src/features/voc-business-workflow/business-workflow.js
  31. 76 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/voc-cost/estimate-report.js
  32. 144 0
      claude-code/claude-code-voc-intelligence/mcp/src/features/voc-cost/workflow-planner.js
  33. 37 7
      claude-code/claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js
  34. 27 150
      claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api.js
  35. 8 4
      claude-code/claude-code-voc-intelligence/mcp/src/providers/ecommerce-gateway.js
  36. 7 4
      claude-code/claude-code-voc-intelligence/mcp/src/providers/overseas-gateway.js
  37. 36 20
      claude-code/claude-code-voc-intelligence/mcp/src/providers/voc-gateway.js
  38. 22 98
      claude-code/claude-code-voc-intelligence/mcp/src/providers/xiaohongshu-api.js
  39. 258 23
      claude-code/claude-code-voc-intelligence/mcp/src/server.js
  40. 265 49
      claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js
  41. 103 27
      claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-api-catalog-run.js
  42. 9 0
      claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-billing-query-run.js
  43. 89 0
      claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-cost-estimate-run.js
  44. 91 0
      claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-usage-report-run.js
  45. 266 48
      claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js
  46. 2 2
      claude-code/claude-code-voc-intelligence/package-lock.json
  47. 7 1
      claude-code/claude-code-voc-intelligence/package.json
  48. 4 1
      claude-code/claude-code-voc-intelligence/scripts/acceptance.js
  49. 79 0
      claude-code/claude-code-voc-intelligence/scripts/billing-client-smoke.js
  50. 2 1
      claude-code/claude-code-voc-intelligence/scripts/fmode-image-analysis-smoke.js
  51. 57 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-budget-guard.js
  52. 54 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-cost-estimate.js
  53. 3 3
      claude-code/claude-code-voc-intelligence/scripts/smoke-mcp.js
  54. 51 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-media-pipeline.js
  55. 84 8
      claude-code/claude-code-voc-intelligence/scripts/smoke-package.js
  56. 133 0
      claude-code/claude-code-voc-intelligence/scripts/smoke-trend-budget.js
  57. 10 5
      claude-code/claude-code-voc-intelligence/skill-package-manifest.json
  58. 7 1
      claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md
  59. 1 1
      claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md
  60. 1 1
      claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/SKILL.md
  61. 16 5
      claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/SKILL.md
  62. 5 8
      claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/references/error-codes.md
  63. 4 0
      claude-code/claude-code-voc-intelligence/skills/voc-business-workflow/SKILL.md
  64. 16 0
      claude-code/claude-code-voc-intelligence/skills/voc-cost-controller/SKILL.md
  65. 19 0
      claude-code/claude-code-voc-intelligence/skills/voc-cost-controller/references/workflow-cost-models.md
  66. 7 1
      claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/SKILL.md
  67. 1 3
      claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/live-mode.md
  68. 10 6
      dist/claude-code-voc-intelligence-suite-manifest.json
  69. BIN
      dist/claude-code-voc-intelligence.zip
  70. 6 6
      dist/npm/claude-code-voc-npm-package-manifest.json

+ 1 - 1
claude-code/claude-code-voc-intelligence/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "voc-intelligence",
   "description": "VOC intelligence skills for Claude Code: end-to-end VOC business workflows, Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode Doubao image analysis, token/recharge checks, evidence-card outputs, prioritized issue pools, single-problem deep dives, competitor maps, 7-day content plans, VOC-backed speaking script co-creation, boss/operator action plans, and multi-turn memory.",
-  "version": "0.3.32",
+  "version": "0.3.33",
   "author": {
     "name": "nkkj-BrainHack"
   }

+ 5 - 11
claude-code/claude-code-voc-intelligence/README.md

@@ -280,19 +280,13 @@ sample 模式不需要 token。live 模式需要一把可用的 token——**两
 
 Claude Code 技能包不读取 `~/.openclaw/voc-credentials.json`。
 
-缺 token 多数是**配置/读取**问题,不是余额不足:应先引导自救(从上述来源读 SK、用 `FMODE_API_KEY` 重试),**只有确认确实是 402 余额不足时**才给充值/开通链接,且不要暴露底层接口 403、权限堆栈或上游响应体。两条链对应**两个不同的充值入口**,不要搭错
+缺 token 多数是**配置/读取**问题,应先恢复凭证;只有确认确实是 402 余额不足时才进入充值流程。充值入口统一使用运行时生成的 Tokenized Balance 链接
 
-- **NewAPI(fmode-api)计费 SK**(`sk-` 开头,多数缺 token 场景走这条)→ fmode-api 余额充值:
-
-  ```text
-  https://app.fmode.cn/dev/studio/?balance=fmodeapi
-  ```
-
-- **VOC-AI 数据服务会话 token**(`r:` 开头)→ apig-pay 充值:
+```text
+https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN
+```
 
-  ```text
-  https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF
-  ```
+链接由工具按当前用户会话生成,不在文档中固定账号、金额或旧 APIG 参数。用户也可以让 Agent 按报价结果调用现有支付码链路直接生成二维码。
 
 ## 记忆能力
 

+ 25 - 0
claude-code/claude-code-voc-intelligence/SKILL.md

@@ -0,0 +1,25 @@
+---
+name: voc-intelligence
+description: 面向经营者的 VOC 用户经营洞察工作台。从社交媒体、电商和海外选品数据中提取用户声音,形成趋势情报、问题池、竞品图谱、单点深挖、7 天内容计划和口播脚本。用户要看顾客关心什么、先改哪里、下周发什么、查商品价格评论或进行样例与真实采集时使用。
+---
+
+# VOC 用户经营洞察
+
+这是一个可从自然语言开始的 VOC 经营工作台,包含趋势情报、问题管理、竞品分析、经营动作、内容计划和口播脚本等子技能,并提供统一的 MCP 数据接口清单。
+
+## 使用规则
+
+- 用户只需描述行业、品类、平台和目标,不要求用户了解 MCP 参数。
+- 用户没有明确要求真实采集时,先使用演示样例;用户明确说真实采集或 live 时,再检查凭证和余额。
+- 首轮输出采用“初步判断、机会假设、待校准”口径,并给出证据和下一步动作。
+- 用户要完整经营闭环时,调用 `voc_business_workflow_run`;要单项任务时,调用对应的趋势、问题池、深挖、竞品、内容或脚本工具。
+- 用户要查商品、价格、评论、销量或其他平台接口时,先用 `voc_api_search` 查清单,再用 `voc_api_doc` 读取参数,最后用 `voc_api_call` 调用。
+- 采集响应中的多媒体统一下载到任务本地 `assets/` 并写入 `media-manifest.json`;报告只引用本地路径,技能包不向线上 Storage 上传。
+
+## 主要能力
+
+- 小红书和抖音趋势情报,支持样例与小规模真实采集。
+- VOC 问题池、单点问题深挖、竞品图谱和 7 天内容计划。
+- 基于用户声音的口播脚本共创、修改和定稿。
+- 淘宝/天猫、京东、1688、抖音电商等电商数据,以及亚马逊海外选品数据。
+- 图片、截图、海报、图表和商品图片分析。

+ 6 - 1
claude-code/claude-code-voc-intelligence/bin/claude-voc.js

@@ -282,6 +282,7 @@ function assertInstalled(targetDir) {
     'skills/voc-business-workflow/SKILL.md',
     'skills/fmode-image-analysis/SKILL.md',
     'skills/voc-api-catalog/SKILL.md',
+    'skills/voc-cost-controller/SKILL.md',
     'mcp/src/tools/fmode-image-analysis.js',
     'mcp/src/tools/voc-api-catalog-run.js',
     'mcp/catalog/voc-social-endpoints.json',
@@ -487,7 +488,7 @@ function workspaceTrendSkillText(options) {
     '## 充值 / 计费口径(简版;完整对照见 voc-api-catalog 技能的「错误码速查」references/error-codes.md)',
     '',
     '- 缺 token(`needs_token`)= 可恢复、不是没钱:先自救读 `~/.claude/settings.json` 的 `sk-` fmode key(或 `~/.fmode/config.json` / `~/.claude/voc-credentials.json`),用 `FMODE_API_KEY=sk-…` 重试,不要劝充值。',
-    '- 只有真 `402 余额不足` 才充值,入口 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`(NewAPI/fmode-api 计费);`401`=token、`403`=权限,都不充值。`apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF` 仅限 `r:` 会话 token 余额补充。',
+    '- 只有真 `402 余额不足` 才充值;充值入口以工具返回的 Tokenized Balance 链接或二维码支付结果为准。链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`,不要在话术中写死账号或旧 APIG 参数。',
     '- 实际充值/自救链接以工具返回的 `assistantMessage` / `nextActions` 为准,不要写死。',
     ''
   ].join('\n');
@@ -736,6 +737,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   const businessWorkflowSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-business-workflow');
   const fmodeImageSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'fmode-image-analysis');
   const apiCatalogSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-api-catalog');
+  const costControllerSkillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'voc-cost-controller');
   ensureDir(xhsSkillDir);
   ensureDir(douyinSkillDir);
   ensureDir(issuePoolSkillDir);
@@ -746,6 +748,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   ensureDir(businessWorkflowSkillDir);
   ensureDir(fmodeImageSkillDir);
   ensureDir(apiCatalogSkillDir);
+  ensureDir(costControllerSkillDir);
   fs.writeFileSync(path.join(xhsSkillDir, 'SKILL.md'), workspaceSkillText(targetDir), 'utf8');
   fs.writeFileSync(path.join(douyinSkillDir, 'SKILL.md'), workspaceDouyinSkillText(targetDir), 'utf8');
   fs.writeFileSync(path.join(issuePoolSkillDir, 'SKILL.md'), workspaceIssuePoolSkillText(targetDir), 'utf8');
@@ -756,6 +759,7 @@ function writeWorkspaceSkillEntries(targetDir) {
   fs.writeFileSync(path.join(businessWorkflowSkillDir, 'SKILL.md'), workspaceBusinessWorkflowSkillText(targetDir), 'utf8');
   fs.copyFileSync(path.join(targetDir, 'skills', 'fmode-image-analysis', 'SKILL.md'), path.join(fmodeImageSkillDir, 'SKILL.md'));
   fs.copyFileSync(path.join(targetDir, 'skills', 'voc-api-catalog', 'SKILL.md'), path.join(apiCatalogSkillDir, 'SKILL.md'));
+  fs.copyFileSync(path.join(targetDir, 'skills', 'voc-cost-controller', 'SKILL.md'), path.join(costControllerSkillDir, 'SKILL.md'));
 }
 
 function writeClaudeWorkspaceActivation(targetDir) {
@@ -772,6 +776,7 @@ function writeClaudeWorkspaceActivation(targetDir) {
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-business-workflow\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\fmode-image-analysis\\SKILL.md');
   console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-api-catalog\\SKILL.md');
+  console.log('Wrote workspace skill entry: .\\.claude\\skills\\voc-cost-controller\\SKILL.md');
 }
 
 function writeWorkspaceActivation(targetDir, opts) {

+ 2 - 0
claude-code/claude-code-voc-intelligence/docs/capability-map.md

@@ -140,11 +140,13 @@
 | `mcp/src/features/*` | 趋势报告、单点深挖、记忆、信号提取 |
 | `mcp/src/core/credentials.js` | token 读取口径 |
 | `mcp/src/core/payment-links.js` | 充值/开通提示 |
+| `mcp/src/core/billing-client.js` / `mcp/src/tools/voc-billing-query-run.js` | 按 Agent 操作计划读取服务端价目、余额、quota 差额和建议充值金额 |
 | `mcp/src/core/evidence-quality.js` | 证据质量门槛、无样本降级建议 |
 | `docs/live-manual-acceptance-checklist.md` | live 采集人工验收 |
 | `docs/claude-code-troubleshooting-playbook.md` | Claude Code 故障定位、误报修复、关键词修复和发散排查 |
 | `docs/payment-package-links.md` | 支付链接和套餐入口说明 |
 | `docs/context-budget-implementation-plan.md` | 上下文省 token 工作流 |
+| `docs/voc-cost-metering-and-balance-implementation-spec.md` | VOC 用量核算、任务成本预估、计费 API 与余额看板实施方案 |
 | `docs/deliverables/trend-insight-report.md` | 行业用户洞察趋势清单模板 |
 | `docs/deliverables/competitor-map-report.md` | 竞品图谱与错位机会报告模板 |
 | `docs/deliverables/voc-issue-pool-report.md` | VOC 问题清单与优先级模板 |

+ 1 - 1
claude-code/claude-code-voc-intelligence/docs/live-manual-acceptance-checklist.md

@@ -8,7 +8,7 @@
 - 不依赖 `~/.openclaw/voc-credentials.json`。
 - 如果使用全局配置,只允许使用 `~/.claude/voc-credentials.json`。
 - 上游余额充足,且充值入口可打开:
-  `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`
+  `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`
 
 ## 小红书 live 最小验收
 

+ 4 - 4
claude-code/claude-code-voc-intelligence/docs/payment-package-links.md

@@ -31,15 +31,15 @@ https://app.fmode.cn/dev/apig-pay/#/workshop
 | 情报官实战包 | https://app.fmode.cn/dev/apig-pay/#/workshop/499 | 隐藏直达,适合企业实训 |
 | 情报官团队包 | https://app.fmode.cn/dev/apig-pay/#/workshop/699 | 公开入口,适合团队共用和复盘 |
 
-## 单 APIG 补额度入口
+## 数据余额入口
 
-当前小红书 live 采集走 VOC 社媒数据中台
+当前小红书 live 采集统一从当前用户 NewAPI 余额扣费。用户自助充值使用带 Session Token 的余额页
 
 ```text
-https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF
+https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN
 ```
 
-如果用户只是社媒数据中台余额不足,可以使用这个入口补额度。课程和工作坊场景优先引导用户买技能套餐,因为技能套餐会同时给社媒数据中台和电商数据中台分配额度
+如果 Agent 已经完成成本测算,也可以直接复用现有 `pay_code2` 链路返回支付二维码。课程和工作坊套餐链接仍只用于套餐购买,不作为数据余额不足时的默认入口
 
 ## 技能包安装入口
 

+ 96 - 0
claude-code/claude-code-voc-intelligence/docs/reports/three-account-platform-cost-2026-09-01.md

@@ -0,0 +1,96 @@
+# 三账号平台成本分析
+
+统计时间:2026-08-31 00:00:00 至 2026-09-01 17:53:20(北京时间)
+
+本文件只展示平台侧成本估算,不包含用户扣费、毛利或账户充值金额。用户实际扣费见独立 HTML 报告。
+
+## 1. 成本总览
+
+- 两个计费账户的平台估算成本合计:¥256.06。
+- 林总分身估算成本:¥120.73。
+- 林煌分身与运营 Agent 共享池估算成本:¥135.33。
+- VOC 数据上游成本:约 ¥57.10。
+- 大模型牌价成本:约 ¥198.96。
+
+## 2. 成本口径
+
+| 成本类型 | 计算方式 | 数据质量 |
+| --- | --- | --- |
+| 小红书 / 社媒 | 按实际转发到上游的端点调用和官方端点价目计算 | 较高 |
+| 淘宝 / 天猫 | 官方日账单暂不可用,按当前端点价目估算 | 估算 |
+| 大模型 | 按 NewAPI 日志中的 `group_ratio` 反推牌价成本 | 估算 |
+
+大模型牌价成本不等同于渠道包月、阶梯折扣或最终结算成本。国内电商成本也需要以后续官方账单校准。
+
+## 3. 成本构成
+
+| 成本模块 | 估算成本 | 占总成本 |
+| --- | ---: | ---: |
+| 林煌分身 + 运营共享池大模型 | ¥135.33 | 52.8% |
+| 林总分身大模型 | ¥63.63 | 24.8% |
+| 小红书 / 社媒数据 | ¥50.90 | 19.9% |
+| 淘宝 / 天猫数据 | 约 ¥6.20 | 2.4% |
+| 合计 | ¥256.06 | 100% |
+
+## 4. VOC 上游成本
+
+### 4.1 小红书
+
+| 接口 | 上游调用 | 估算成本 |
+| --- | ---: | ---: |
+| 获取账号已发布笔记 | 343 | ¥24.70 |
+| 搜索账号 | 102 | ¥7.34 |
+| 获取笔记评论 | 81 | ¥5.83 |
+| 搜索笔记 | 66 | ¥4.75 |
+| 图文笔记详情 | 58 | ¥4.18 |
+| 获取账号信息 | 26 | ¥1.87 |
+| 视频笔记详情 | 20 | ¥1.44 |
+| 子评论 | 10 | ¥0.72 |
+| Web 笔记详情 | 1 | ¥0.07 |
+| 合计 | 707 | ¥50.90 |
+
+### 4.2 淘宝 / 天猫
+
+| 接口 | 上游调用 | 估算成本 |
+| --- | ---: | ---: |
+| 商品详情 v4 | 8 | 约 ¥1.60 |
+| 商品评论 v3 | 8 | 约 ¥1.60 |
+| 商品详情 v3 | 7 | 约 ¥1.40 |
+| 店铺商品列表 v4 | 5 | 约 ¥1.00 |
+| 商品详情 v2 | 1 | 约 ¥0.20 |
+| 商品详情 v5 | 1 | 约 ¥0.20 |
+| 商品搜索 v1 | 1 | 约 ¥0.20 |
+| 合计 | 31 | 约 ¥6.20 |
+
+## 5. 大模型牌价成本
+
+### 林总分身
+
+| 模型 | 调用 | 估算牌价成本 |
+| --- | ---: | ---: |
+| Claude Sonnet | 153 | ¥59.81 |
+| DeepSeek Flash | 355 | ¥3.76 |
+| Claude Haiku | 5 | ¥0.06 |
+| 合计 | 513 | ¥63.63 |
+
+### 林煌分身与运营 Agent 共享池
+
+| 模型 | 调用 | 估算牌价成本 |
+| --- | ---: | ---: |
+| GLM Flash | 35 | ¥127.04 |
+| DeepSeek Flash | 305 | ¥8.13 |
+| GPT-4o | 2 | ¥0.15 |
+| 合计 | 342 | ¥135.33 |
+
+## 6. 成本侧结论
+
+1. VOC 上游成本的最大单项是“小红书账号已发布笔记”,估算 ¥24.70。
+2. 大模型成本明显高于 VOC 数据成本,尤其是共享池 GLM Flash,估算 ¥127.04。
+3. 成本优化应优先关注高频接口和高单次上下文请求,并在后续日志中保留可追踪的执行标识。
+4. 后续需要用渠道实际结算账单校准大模型与国内电商估算值,并在日志中记录成本版本。
+
+## 7. 数据边界
+
+1. 共享池当前不能拆分林煌分身与运营 Agent。
+2. 当前没有 `usageTraceId/reportId`,报告场景归因依赖账户、接口与执行时间。
+3. 本文件不包含任何 Session Token、API Key、Bot Secret、密码或支付凭证。

+ 328 - 0
claude-code/claude-code-voc-intelligence/docs/reports/three-account-user-billing-2026-09-01.html

@@ -0,0 +1,328 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>三账号用户实际扣费报告</title>
+  <style>
+    :root {
+      --ink: #17201d;
+      --muted: #63706b;
+      --line: #dfe5e2;
+      --paper: #ffffff;
+      --canvas: #f3f6f4;
+      --green: #147d64;
+      --green-soft: #e5f3ee;
+      --blue: #2667a7;
+      --blue-soft: #e8f0f8;
+      --amber: #a86510;
+      --amber-soft: #fbf1df;
+      --red: #a43d38;
+    }
+
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      color: var(--ink);
+      background: var(--canvas);
+      font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
+      font-size: 14px;
+      line-height: 1.6;
+      letter-spacing: 0;
+    }
+    main { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 56px; }
+    header { padding: 26px 0 22px; border-bottom: 2px solid var(--ink); }
+    h1, h2, h3, p { margin-top: 0; }
+    h1 { margin-bottom: 8px; font-size: 30px; line-height: 1.25; font-weight: 750; }
+    h2 { margin-bottom: 16px; font-size: 20px; line-height: 1.35; }
+    h3 { margin-bottom: 10px; font-size: 15px; }
+    .eyebrow { margin-bottom: 7px; color: var(--green); font-size: 12px; font-weight: 700; text-transform: uppercase; }
+    .meta { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--muted); }
+    .note { margin-top: 14px; max-width: 900px; color: var(--muted); }
+    section { padding: 28px 0; border-bottom: 1px solid var(--line); }
+    .kpis { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+    .kpi { min-height: 118px; padding: 18px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); }
+    .kpi-label { color: var(--muted); font-size: 12px; }
+    .kpi-value { margin-top: 8px; font-size: 27px; line-height: 1.2; font-weight: 750; font-variant-numeric: tabular-nums; }
+    .kpi-sub { margin-top: 8px; color: var(--muted); font-size: 12px; }
+    .kpi.primary { border-top: 4px solid var(--green); }
+    .kpi.model { border-top: 4px solid var(--blue); }
+    .kpi.voc { border-top: 4px solid var(--amber); }
+    .callout { padding: 14px 16px; border-left: 4px solid var(--amber); background: var(--amber-soft); }
+    .callout strong { color: #704204; }
+    .split { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
+    .panel { padding: 18px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); }
+    .bar-row { margin: 14px 0; }
+    .bar-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 6px; }
+    .bar-head strong { font-variant-numeric: tabular-nums; white-space: nowrap; }
+    .bar-track { height: 10px; overflow: hidden; background: #edf1ef; border-radius: 2px; }
+    .bar { height: 100%; background: var(--green); }
+    .bar.blue { background: var(--blue); }
+    .bar.amber { background: var(--amber); }
+    .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); }
+    table { width: 100%; border-collapse: collapse; min-width: 760px; }
+    th, td { padding: 11px 13px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
+    th { background: #f7f9f8; color: #47534f; font-size: 12px; font-weight: 700; }
+    tr:last-child td { border-bottom: 0; }
+    td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+    .tag { display: inline-block; padding: 2px 7px; border-radius: 3px; background: var(--green-soft); color: var(--green); font-size: 12px; font-weight: 700; }
+    .tag.shared { background: var(--blue-soft); color: var(--blue); }
+    .summary-list { margin: 0; padding-left: 20px; }
+    .summary-list li { margin: 7px 0; }
+    .foot { padding-top: 22px; color: var(--muted); font-size: 12px; }
+    .money { font-weight: 700; }
+    .danger { color: var(--red); }
+
+    @media (max-width: 860px) {
+      main { width: min(100% - 20px, 680px); padding-top: 14px; }
+      header { padding-top: 18px; }
+      h1 { font-size: 24px; }
+      .kpis { grid-template-columns: 1fr 1fr; }
+      .split { grid-template-columns: 1fr; }
+    }
+    @media (max-width: 520px) {
+      .kpis { grid-template-columns: 1fr; }
+      .kpi { min-height: 0; }
+      section { padding: 22px 0; }
+      .meta { display: block; }
+      .meta span { display: block; margin-bottom: 3px; }
+    }
+    @media print {
+      body { background: #fff; }
+      main { width: 100%; padding: 0; }
+      .kpi, .panel, .table-wrap { break-inside: avoid; }
+    }
+  </style>
+</head>
+<body>
+  <main>
+    <header>
+      <div class="eyebrow">NewAPI billing / actual charged amount</div>
+      <h1>三账号用户实际扣费报告</h1>
+      <div class="meta">
+        <span>统计区间:2026-08-31 00:00 至 2026-09-01 17:53</span>
+        <span>时区:Asia/Shanghai</span>
+      </div>
+    </header>
+
+    <section aria-labelledby="overview-title">
+      <h2 id="overview-title">扣费总览</h2>
+      <div class="kpis">
+        <div class="kpi primary">
+          <div class="kpi-label">两个计费账户合计扣费</div>
+          <div class="kpi-value">¥620.18</div>
+          <div class="kpi-sub">共 2,009 次计费调用</div>
+        </div>
+        <div class="kpi model">
+          <div class="kpi-label">大模型扣费</div>
+          <div class="kpi-value">¥497.38</div>
+          <div class="kpi-sub">855 次,占总扣费 80.2%</div>
+        </div>
+        <div class="kpi voc">
+          <div class="kpi-label">VOC 数据扣费</div>
+          <div class="kpi-value">¥122.80</div>
+          <div class="kpi-sub">1,154 次,占总扣费 19.8%</div>
+        </div>
+      </div>
+    </section>
+
+    <section aria-labelledby="account-title">
+      <h2 id="account-title">按账号查看</h2>
+      <div class="callout"><strong>账号归属说明:</strong>林煌分身与运营 Agent 共用同一 NewAPI 用户和 API Key,本报告按共享池合计展示两者的实际扣费。</div>
+      <div class="table-wrap" style="margin-top:16px">
+        <table>
+          <thead>
+            <tr>
+              <th>业务身份</th>
+              <th>计费归属</th>
+              <th class="num">VOC 调用</th>
+              <th class="num">VOC 扣费</th>
+              <th class="num">大模型调用</th>
+              <th class="num">大模型扣费</th>
+              <th class="num">合计扣费</th>
+              <th class="num">余额快照</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td>林总分身</td>
+              <td><span class="tag">独立账户</span></td>
+              <td class="num">1,154</td>
+              <td class="num money">¥122.80</td>
+              <td class="num">513</td>
+              <td class="num money">¥159.06</td>
+              <td class="num money">¥281.86</td>
+              <td class="num">¥149.44</td>
+            </tr>
+            <tr>
+              <td>林煌分身</td>
+              <td><span class="tag shared">共享账户</span></td>
+              <td class="num">0</td>
+              <td class="num">¥0.00</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享 ¥101.68</td>
+            </tr>
+            <tr>
+              <td>运营 Agent</td>
+              <td><span class="tag shared">共享账户</span></td>
+              <td class="num">0</td>
+              <td class="num">¥0.00</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享池合计</td>
+              <td class="num">共享 ¥101.68</td>
+            </tr>
+            <tr>
+              <td>林煌分身 + 运营 Agent 共享池</td>
+              <td><span class="tag shared">一个计费账户</span></td>
+              <td class="num">0</td>
+              <td class="num">¥0.00</td>
+              <td class="num">342</td>
+              <td class="num money">¥338.32</td>
+              <td class="num money">¥338.32</td>
+              <td class="num">¥101.68</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section aria-labelledby="composition-title">
+      <h2 id="composition-title">扣费构成</h2>
+      <div class="split">
+        <div class="panel">
+          <h3>按计费模块</h3>
+          <div class="bar-row">
+            <div class="bar-head"><span>大模型</span><strong>¥497.38 · 80.2%</strong></div>
+            <div class="bar-track"><div class="bar blue" style="width:80.2%"></div></div>
+          </div>
+          <div class="bar-row">
+            <div class="bar-head"><span>VOC 数据</span><strong>¥122.80 · 19.8%</strong></div>
+            <div class="bar-track"><div class="bar amber" style="width:19.8%"></div></div>
+          </div>
+        </div>
+        <div class="panel">
+          <h3>按计费账户</h3>
+          <div class="bar-row">
+            <div class="bar-head"><span>林煌分身 + 运营共享池</span><strong>¥338.32 · 54.6%</strong></div>
+            <div class="bar-track"><div class="bar blue" style="width:54.6%"></div></div>
+          </div>
+          <div class="bar-row">
+            <div class="bar-head"><span>林总分身</span><strong>¥281.86 · 45.4%</strong></div>
+            <div class="bar-track"><div class="bar" style="width:45.4%"></div></div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <section aria-labelledby="voc-title">
+      <h2 id="voc-title">VOC 用户扣费明细</h2>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>模块 / 场景</th><th class="num">调用次数</th><th class="num">次数占比</th><th class="num">用户扣费</th></tr></thead>
+          <tbody>
+            <tr><td>小红书 280 账号监听与排行</td><td class="num">1,117</td><td class="num">96.8%</td><td class="num money">¥111.70</td></tr>
+            <tr><td>淘宝 / 天猫商品检查</td><td class="num">37</td><td class="num">3.2%</td><td class="num money">¥11.10</td></tr>
+            <tr><td><strong>合计</strong></td><td class="num"><strong>1,154</strong></td><td class="num"><strong>100%</strong></td><td class="num money">¥122.80</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section aria-labelledby="xhs-detail-title">
+      <h2 id="xhs-detail-title">场景一:小红书 280 账号监听与排行</h2>
+      <p class="note">以下数据按该场景实际产生的接口日志汇总;quota 为 NewAPI 额度消耗,人民币金额为用户实际扣费。</p>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>接口</th><th class="num">调用频次</th><th class="num">场景占比</th><th class="num">NewAPI quota 消耗</th><th class="num">quota 占比</th><th class="num">用户扣费</th></tr></thead>
+          <tbody>
+            <tr><td>获取账号已发布笔记</td><td class="num">729</td><td class="num">65.3%</td><td class="num">5,360,337</td><td class="num">65.3%</td><td class="num money">¥72.90</td></tr>
+            <tr><td>搜索账号</td><td class="num">114</td><td class="num">10.2%</td><td class="num">838,242</td><td class="num">10.2%</td><td class="num money">¥11.40</td></tr>
+            <tr><td>获取笔记评论</td><td class="num">81</td><td class="num">7.2%</td><td class="num">595,593</td><td class="num">7.2%</td><td class="num money">¥8.10</td></tr>
+            <tr><td>搜索笔记</td><td class="num">75</td><td class="num">6.7%</td><td class="num">551,475</td><td class="num">6.7%</td><td class="num money">¥7.50</td></tr>
+            <tr><td>图文笔记详情</td><td class="num">60</td><td class="num">5.4%</td><td class="num">441,180</td><td class="num">5.4%</td><td class="num money">¥6.00</td></tr>
+            <tr><td>获取账号信息</td><td class="num">27</td><td class="num">2.4%</td><td class="num">198,531</td><td class="num">2.4%</td><td class="num money">¥2.70</td></tr>
+            <tr><td>视频笔记详情</td><td class="num">20</td><td class="num">1.8%</td><td class="num">147,060</td><td class="num">1.8%</td><td class="num money">¥2.00</td></tr>
+            <tr><td>子评论</td><td class="num">10</td><td class="num">0.9%</td><td class="num">73,530</td><td class="num">0.9%</td><td class="num money">¥1.00</td></tr>
+            <tr><td>Web 笔记详情</td><td class="num">1</td><td class="num">0.1%</td><td class="num">7,353</td><td class="num">0.1%</td><td class="num money">¥0.10</td></tr>
+            <tr><td><strong>小红书场景合计</strong></td><td class="num"><strong>1,117</strong></td><td class="num"><strong>100%</strong></td><td class="num"><strong>8,213,301</strong></td><td class="num"><strong>100%</strong></td><td class="num money">¥111.70</td></tr>
+          </tbody>
+        </table>
+      </div>
+      <div class="panel" style="margin-top:14px">
+        <h3>下一版建议</h3>
+        <ul class="summary-list">
+          <li><strong>保留:</strong>搜索账号、获取账号信息、获取账号已发布笔记,它们直接支撑账号入册、发帖统计和排行。</li>
+          <li><strong>按需保留:</strong>笔记评论、图文详情、视频详情;只对进入排行或重点分析的账号/笔记执行深采。</li>
+          <li><strong>优先降采样:</strong>获取账号已发布笔记占本场景 quota 65.3%,可先限制时间范围、分页深度或每账号最大笔记数。</li>
+          <li><strong>可选剔除:</strong>子评论和 Web 详情合计仅 0.1% quota,若报告不需要逐层评论或兼容页面详情,可直接关闭。</li>
+        </ul>
+      </div>
+    </section>
+
+    <section aria-labelledby="tmall-detail-title">
+      <h2 id="tmall-detail-title">场景二:天猫旗舰店商品检查与 8 个代表 SKU 打分</h2>
+      <p class="note">以下数据按该场景实际产生的接口日志汇总;quota 为 NewAPI 额度消耗,人民币金额为用户实际扣费。</p>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>接口</th><th class="num">调用频次</th><th class="num">场景占比</th><th class="num">NewAPI quota 消耗</th><th class="num">quota 占比</th><th class="num">用户扣费</th></tr></thead>
+          <tbody>
+            <tr><td>商品详情 v4</td><td class="num">10</td><td class="num">27.0%</td><td class="num">220,590</td><td class="num">27.0%</td><td class="num money">¥3.00</td></tr>
+            <tr><td>商品评论 v3</td><td class="num">9</td><td class="num">24.3%</td><td class="num">198,531</td><td class="num">24.3%</td><td class="num money">¥2.70</td></tr>
+            <tr><td>商品详情 v3</td><td class="num">7</td><td class="num">18.9%</td><td class="num">154,413</td><td class="num">18.9%</td><td class="num money">¥2.10</td></tr>
+            <tr><td>店铺商品列表 v4</td><td class="num">6</td><td class="num">16.2%</td><td class="num">132,354</td><td class="num">16.2%</td><td class="num money">¥1.80</td></tr>
+            <tr><td>商品详情 v2</td><td class="num">2</td><td class="num">5.4%</td><td class="num">44,118</td><td class="num">5.4%</td><td class="num money">¥0.60</td></tr>
+            <tr><td>商品详情 v5</td><td class="num">2</td><td class="num">5.4%</td><td class="num">44,118</td><td class="num">5.4%</td><td class="num money">¥0.60</td></tr>
+            <tr><td>商品搜索 v1</td><td class="num">1</td><td class="num">2.7%</td><td class="num">22,059</td><td class="num">2.7%</td><td class="num money">¥0.30</td></tr>
+            <tr><td><strong>天猫场景合计</strong></td><td class="num"><strong>37</strong></td><td class="num"><strong>100%</strong></td><td class="num"><strong>816,183</strong></td><td class="num"><strong>100%</strong></td><td class="num money">¥11.10</td></tr>
+          </tbody>
+        </table>
+      </div>
+      <div class="panel" style="margin-top:14px">
+        <h3>下一版建议</h3>
+        <ul class="summary-list">
+          <li><strong>保留:</strong>店铺商品列表 v4 获取全店 SKU,商品详情 v4 和商品评论 v3 深采 8 个代表 SKU。</li>
+          <li><strong>按需保留:</strong>商品详情 v3 只在 v4 缺字段时补充,避免同一 SKU 默认重复请求多个详情版本。</li>
+          <li><strong>优先剔除:</strong>商品详情 v2、v5 属于版本探测,若已有稳定主版本,应从标准报告流程中关闭。</li>
+          <li><strong>可选剔除:</strong>商品搜索 v1 在已有店铺列表时没有必要重复执行。</li>
+        </ul>
+      </div>
+    </section>
+
+    <section aria-labelledby="model-title">
+      <h2 id="model-title">大模型用户扣费明细</h2>
+      <div class="table-wrap">
+        <table>
+          <thead><tr><th>计费账户</th><th>模型</th><th class="num">调用</th><th class="num">账户内调用占比</th><th class="num">用户扣费</th></tr></thead>
+          <tbody>
+            <tr><td>林总分身</td><td>Claude Sonnet</td><td class="num">153</td><td class="num">29.8%</td><td class="num money">¥149.52</td></tr>
+            <tr><td>林总分身</td><td>DeepSeek Flash</td><td class="num">355</td><td class="num">69.2%</td><td class="num money">¥9.39</td></tr>
+            <tr><td>林总分身</td><td>Claude Haiku</td><td class="num">5</td><td class="num">1.0%</td><td class="num money">¥0.15</td></tr>
+            <tr><td>林煌分身 + 运营共享池</td><td>GLM Flash</td><td class="num">35</td><td class="num">10.2%</td><td class="num money danger">¥317.61</td></tr>
+            <tr><td>林煌分身 + 运营共享池</td><td>DeepSeek Flash</td><td class="num">305</td><td class="num">89.2%</td><td class="num money">¥20.33</td></tr>
+            <tr><td>林煌分身 + 运营共享池</td><td>GPT-4o</td><td class="num">2</td><td class="num">0.6%</td><td class="num money">¥0.38</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section aria-labelledby="finding-title">
+      <h2 id="finding-title">用户扣费结论</h2>
+      <ol class="summary-list">
+        <li>100 元以上的 VOC 消耗主要来自小红书 280 账号任务,扣费 <span class="money">¥111.70</span>;天猫代表商品链路扣费 <span class="money">¥11.10</span>。</li>
+        <li>账号已发布笔记接口单项扣费 <span class="money">¥72.90</span>,占全部 VOC 扣费 59.4%,是首要优化对象。</li>
+        <li>小红书场景占 VOC 用户扣费的 91.0%,是本周期最主要的数据服务消费场景。</li>
+        <li>共享池的大模型扣费中,GLM Flash 仅 35 次调用却扣费 <span class="money">¥317.61</span>,需要继续核查单次上下文和输出量。</li>
+        <li>若要分别核算林煌分身与运营 Agent,后续请求至少要写入独立的 token 名称或 Agent 标识,并在日志中持久化。</li>
+      </ol>
+    </section>
+
+    <div class="foot">
+      <div>数据边界:余额为查询时快照;共享账户目前没有可用于拆分业务身份的 agentId / clientId / usageTraceId。</div>
+      <div>安全说明:本报告不包含 Session Token、API Key、Bot Secret、密码或支付凭证。</div>
+    </div>
+  </main>
+</body>
+</html>

+ 10 - 0
claude-code/claude-code-voc-intelligence/docs/reports/three-account-voc-usage-2026-09-01.md

@@ -0,0 +1,10 @@
+# 三账号用量报告交付索引
+
+用户实际扣费与平台成本已按不同核算口径拆分,不再在同一文档中混合展示:
+
+1. 用户实际扣费 HTML:`three-account-user-billing-2026-09-01.html`
+2. 平台成本分析:`three-account-platform-cost-2026-09-01.md`
+
+用户扣费报告只展示生产 NewAPI 日志中的实际扣款;平台成本文档只展示上游成本和牌价成本估算。
+
+统计区间:2026-08-31 00:00:00 至 2026-09-01 17:53:20(北京时间)。

+ 156 - 0
claude-code/claude-code-voc-intelligence/docs/taobao-item-detail-v7.md

@@ -0,0 +1,156 @@
+# 淘宝/天猫商品详情 V7 接口使用文档
+
+## 1. 适用范围
+
+V7 用于实时发起淘宝/天猫商品基础详情采集,适合获取较新的标题、基础价格、SKU、库存和店铺信息。
+
+V7 不是商品页全部字段的统一接口。以下信息需要按口径补充其它版本:
+
+- 页面展示的“已售/付款人数”:补充调用 V3,读取 `data.sellCount`。
+- 较完整的商品属性、SKU 标价和促销价:补充调用 V6。
+- 券后价或优惠后价:补充调用 V4。
+- 账号、地区、会员、店铺券和平台补贴共同影响的最终结算价:以实际结算页为准。
+
+## 2. 接口信息
+
+| 项目 | 值 |
+|---|---|
+| 接口 ID | `taobao.get_item_detail_v7` |
+| 请求路径 | `taobao/get-item-detail/v7` |
+| 请求方法 | `GET` |
+| 参数位置 | Query |
+| 通道 | `ecommerce` |
+| 计费 | 1 次接口调用 |
+| 推荐时效门槛 | 48 小时 |
+
+## 3. 请求参数
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `itemId` | string | 是 | 淘宝或天猫商品 ID。可从商品链接的 `id` 参数中提取。 |
+
+示例商品链接:
+
+```text
+https://detail.tmall.com/item.htm?id=824950233983
+```
+
+对应 `itemId`:
+
+```text
+824950233983
+```
+
+## 4. 使用 `voc_api_call` 调用
+
+推荐使用清单 ID 调用,工具会自动选择 `ecommerce` 通道并应用重试、业务码识别和时效检查:
+
+```json
+{
+  "id": "taobao.get_item_detail_v7",
+  "params": {
+    "itemId": "824950233983"
+  }
+}
+```
+
+也可以先读取接口文档:
+
+```json
+{
+  "id": "taobao.get_item_detail_v7"
+}
+```
+
+不经过清单 ID 时,等价的原始路径调用参数为:
+
+```json
+{
+  "rawPath": "taobao/get-item-detail/v7",
+  "method": "GET",
+  "channel": "ecommerce",
+  "query": {
+    "itemId": "824950233983"
+  }
+}
+```
+
+## 5. 核心返回字段
+
+成功时,`voc_api_call` 返回 `status=ok`,上游结果位于 `data.result`,完整原始响应位于 `data.raw`。V7 重点读取以下字段:
+
+| 字段 | 含义 | 使用规则 |
+|---|---|---|
+| `subject` | 当前采集到的商品标题 | 可作为当前基础标题,但仍需结合 `recordTime` 判断时效。 |
+| `price` | 基础价格 | 标注为 V7 基础价,不表述为任意账号的最终结算价。 |
+| `skuVoList` | SKU 列表 | 可读取规格、SKU 价格和库存;数组长度可作为本次采集到的 SKU 数量。 |
+| `shopName` | 店铺名称 | 用于店铺识别。 |
+| `recordTime` | 上游数据采集时间 | 不是本次 API 请求时间,必须计算滞后时长。 |
+| `monthSold` | 月销相关字段 | 可能为 `null`,不作为稳定的页面销量来源。 |
+| `baseAttrList` | 基础属性列表 | 可能为 `null` 或不完整,关键属性应补充 V6。 |
+
+工具会在 `summary` 中返回时效判断信息:
+
+```json
+{
+  "recordTime": "2026-09-01T16:06:58",
+  "maxAgeHours": 48,
+  "ageHours": 0.5,
+  "stale": false
+}
+```
+
+## 6. 时效规则
+
+1. V7 会在调用时实时发起采集,但“实时发起”不等于每个返回字段都与商品页面同一秒刷新。
+2. `recordTime` 表示上游完成数据采集的时间,不是 VOC API 的请求时间。
+3. 技能包用“当前时间 - `recordTime`”计算 `ageHours`。
+4. `ageHours <= 48` 时,结果可作为当前基础详情使用。
+5. `ageHours > 48` 时,工具返回 `status=stale_data`;数据仍会保留在结果中,但只能作为历史参考。
+6. 上游未承诺固定刷新 SLA,因此结果中应同时注明接口版本、`recordTime` 和价格/销量口径。
+
+## 7. 状态与异常处理
+
+| 状态 | 含义 | 处理方式 |
+|---|---|---|
+| `ok` | V7 成功返回,且未超过 48 小时 | 读取标题、基础价、SKU、库存和店铺信息;按需补 V3/V4/V6。 |
+| `stale_data` | `recordTime` 已超过 48 小时 | 仅作历史参考,稍后重试 V7,不表述为当前页面数据。 |
+| `upstream_unstable` | 上游业务码 `301`、5xx、连接失败或重试后仍未恢复 | 工具默认自动重试;连续失败后稍后再次调用。 |
+| `not_supported` | 上游业务码 `202` | 当前商品或数据口径不受该接口支持,检查商品是否下架,并改用其它可用版本核验。 |
+| `needs_input` | 缺少 `itemId` 或参数格式有误 | 从商品链接提取正确的数字商品 ID 后重试。 |
+| `needs_valid_token` | token 缺失、失效或未被正确读取 | 配置有效的技能包调用凭据后重试。 |
+
+HTTP 200 只代表网关请求成功。响应内嵌的业务码 `301` 或 `202` 不会再被技能包误判为成功商品详情。
+
+## 8. 推荐调用顺序
+
+1. 调用 V7 获取较新的标题、基础价格、SKU、库存、店铺和 `recordTime`。
+2. 检查工具状态以及 `summary.ageHours`,只使用 48 小时内的数据作为当前基础详情。
+3. V7 返回 `301` 时让工具完成自动重试;连续失败后稍后再次调用同一接口。
+4. 需要页面展示销量时调用 V3,并同样检查其 `recordTime` 和 48 小时时效门禁。
+5. 需要完整属性或标价/促销价时调用 V6;需要券后价时调用 V4。
+6. 汇总时分别标注“V7 基础价”“V6 标价/促销价”“V4 优惠后价”“V3 页面销量”,不要合并成一个无口径说明的“真实价格/付款人数”。
+
+## 9. 版本职责对照
+
+| 版本 | 主要用途 | 重点字段/口径 | 注意事项 |
+|---|---|---|---|
+| V7 | 新鲜基础详情,首选 | `subject`、`price`、`skuVoList`、`shopName`、`recordTime` | `monthSold`、`baseAttrList` 可能为空。 |
+| V3 | 页面展示销量 | `data.sellCount` | 不返回价格;超过 48 小时后不能称为当前销量。 |
+| V4 | 优惠后价/券后价 | `DiscountPrice`、SKU 最终价字段 | 仍会受账号、SKU、地区和活动影响。 |
+| V6 | 较完整详情和属性 | 标价、促销价、SKU、属性 | 可能返回历史快照,必须检查 `recordTime`。 |
+| V9 | 兼容旧口径 | `num` 为销售量级信号,`totalCount` 为评价总量 | 两者都不是商品页当前付款人数,属性也可能是旧快照。 |
+
+## 10. 2026-09-01 实测记录
+
+| 商品 ID | V7 结果 | 说明 |
+|---|---|---|
+| `824950233983` | 成功;`recordTime=2026-09-01T16:06:58`;基础价 21.8;7 个 SKU | V7 返回了当天的新鲜基础详情。 |
+| `865522000056` | 成功;基础价 33.8;3 个 SKU | 可用于基础价格和 SKU 核验。 |
+| `872904000992` | 首次 `301`,重试后成功;基础价 32;12 个 SKU | 说明 `301` 应先重试,不能直接作为“无数据”。 |
+| `798980050440` | 多次返回 `301` | 本次采集不稳定,按 `upstream_unstable` 处理。 |
+| `1053323886896` | 返回 `301`,页面商品已不存在 | 商品下架或不存在时,不应继续使用旧版本残留快照作为当前详情。 |
+
+## 11. 结论
+
+需要一个优先返回较新基础详情的接口时,使用 V7。需要完整复刻淘宝/天猫商品页时,仍需按字段口径组合 V7、V3、V4 和 V6;当前不存在一个稳定覆盖页面标题、全部属性、页面销量、所有 SKU 优惠和最终结算价的单一接口。

+ 1936 - 0
claude-code/claude-code-voc-intelligence/docs/voc-cost-metering-and-balance-implementation-spec.md

@@ -0,0 +1,1936 @@
+# VOC 用量核算、成本预估与余额看板实现方案
+
+> 文档状态:可进入技术评审
+> 编写日期:2026-09-01
+> 涉及仓库:`claude-code-voc-intelligence`、`future-server`、`fmode-studio`、`voc-profit-web`
+
+## 1. 背景与目标
+
+当前企业用户已经能使用 VOC 技能完成批量社媒监听、电商商品采集和报告生成,但在成本侧存在四个直接影响交付的问题:
+
+1. 任务执行前,AI 不能回答“这项工作预计花多少钱”。
+2. 任务执行后,用户不能按报告、任务、模块和接口查看调用次数与消耗。
+3. 余额页只能看到总余额、已消费和充值信息,不能解释钱花在了哪里。
+4. 技能只知道接口参数,不知道可查询、可版本化的计费规则,无法主动做低成本方案比较。
+
+本方案目标是形成一个统一闭环:
+
+```text
+任务理解
+  -> 生成采集计划
+  -> 查询当前生效价目
+  -> 返回低/中/高三档成本预估
+  -> 检查余额与预算上限
+  -> 携带任务追踪标识执行
+  -> 余额不足时由 Agent 拼接现有 Balance 直达链接或取得现有支付二维码
+  -> 现有充值链路完成支付和余额刷新,Agent 从本地检查点继续
+  -> 记录每次实际扣费
+  -> 缓存采集结果中的多媒体到本地任务目录
+  -> 按任务/报告/模块/接口出账单
+  -> AI 给出高成本低价值项的优化建议
+```
+
+本方案不在技能包、报告、日志或示例中保存账号密钥、会话令牌、API Key。历史敏感清单仅作为账户归属核对输入,不进入代码和 Git。
+
+## 2. 用户要能直接得到的答案
+
+### 2.1 执行前
+
+用户用自然语言提问:
+
+- 监听 280 个小红书账号并生成日报,大概多少钱?
+- 拉取一个天猫店 134 个商品并生成 Listing 诊断,预计消耗多少?
+- 当前余额够不够?
+- 经济版、标准版、完整版分别采什么,价格和结果差异是什么?
+
+AI 应返回:
+
+- 任务规模和计算假设。
+- 每个模块、接口的预计调用次数、单价和小计。
+- 大模型生成费用与 VOC 数据费用分开列示。
+- 低值、高值和预期值,而不是单一伪精确数字。
+- 当前余额、执行后预计余额、预算风险。
+- 可选的降本方案及其信息损失。
+
+### 2.2 执行中
+
+- 达到用户预算的 80% 时提醒。
+- 达到硬预算上限时停止新增采集,保留已采数据并生成部分报告。
+- 余额不足时,Agent 计算差额和建议充值金额,直接拼接带 Token 的 Balance 链接。
+- Agent 根据交互环境返回免登录直达付费页,或直接返回支付二维码。
+- 现有 Balance 页面完成支付和余额刷新,Agent 查询新余额后使用原检查点继续执行。
+- 重试、缓存命中、失败调用的计费状态可解释。
+
+### 2.3 执行后
+
+- 本任务总消费、VOC 数据消费、大模型消费。
+- 每个接口的调用次数、成功/失败/缓存次数、额度和人民币消耗。
+- 按报告、技能、步骤、账号或商品批次下钻。
+- 成本 Top 项及其业务贡献。
+- “删掉什么能省多少钱、会损失什么”的优化建议。
+- 报告引用的图片、视频和音频均来自本地任务目录,不依赖易失效的上游临时 URL,也不上传到线上 Storage。
+
+## 3. 现有实现盘点(改造前基线)
+
+本节记录方案启动时的缺口,便于对照改造范围;当前实现状态以第 18.1 节和第 21 节的验收记录为准。
+
+### 3.1 VOC 技能包
+
+相关文件:
+
+- `skills/voc-api-catalog/SKILL.md`
+- `mcp/catalog/voc-social-endpoints.json`
+- `mcp/src/core/api-catalog.js`
+- `mcp/src/core/credentials.js`
+- `mcp/src/core/payment-links.js`
+- `mcp/src/core/activation.js`
+- `mcp/src/tools/voc-api-catalog-run.js`
+- `mcp/src/providers/voc-gateway.js`
+- `mcp/src/providers/ecommerce-gateway.js`
+- `mcp/src/providers/overseas-gateway.js`
+- `mcp/src/providers/xiaohongshu-api.js`
+- `mcp/src/providers/douyin-api.js`
+- `mcp/src/features/xiaohongshu-trend/live-collector.js`
+- `mcp/src/features/douyin-trend/live-collector.js`
+- `mcp/src/features/voc-business-workflow/business-workflow.js`
+- `mcp/src/features/fmode-image-analysis/image-analysis.js`
+- `mcp/src/server.js`
+
+已具备:
+
+- 接口目录、参数文档和三类网关路由。
+- NewAPI token 优先、会话 token 回退。
+- 401、402、403、参数错误和上游波动的区分。
+- 余额不足时返回充值入口。
+- 小规模 live 默认值和部分结果保留。
+
+缺少:
+
+- 目录中的接口级计费字段和价目版本。
+- 任务执行前的调用计划与成本估算工具。
+- `usageTraceId/reportId/skillId/stepId` 等用量关联字段。
+- 每次调用实际扣费的结构化返回。
+- 整个报告工作流的预算上限和成本汇总。
+- 大模型用量与 VOC 数据调用的统一任务归集。
+- Agent 根据建议金额调用现有免登录 Balance/二维码能力,并在到账后继续的闭环。
+- 对所有采集多媒体统一执行本地缓存和报告 URL 替换的强制流程;本技能包不执行线上 Storage 上传。
+- 类型化区分 `sk-` NewAPI Token 与 `r:` Session Token,并让专用采集器和通用网关共享同一计费/充值上下文。
+
+### 3.2 后端计费
+
+相关文件:
+
+- `fmode-server/modules/shared/newapi-metering.ts`
+- `fmode-server/modules/voc-social-api/src/routes.ts`
+- `fmode-server/modules/voc-e-commerce/src/routes.ts`
+- `fmode-server/modules/voc-ecom-api/src/routes.ts`
+- `api/api-ncloud/fmode/routes.js`
+- `api/api-ncloud/fmode/doc/migrate-apig-newapi-billing.js`
+- `api/api-ncloud/profit/service/revenue.service.js`
+
+已具备:
+
+- `APIGNewApiBillingMap` 保存模块级 `model/service/billingMode/priceCny/quota`。
+- `chargeNewApiUsage()` 同步扣减用户和 token 额度,并写入 NewAPI `logs`。
+- `logs.other` 已记录 `billing_source/apig_id/price_cny/billing_mode/request_path/source/service`。
+- 计费前余额检查、402 余额不足和 `requestId` 幂等控制。
+- 经营看板已经能按模块、端点、用户聚合调用量、额度和金额。
+- 缓存命中与上游转发已经写入 `source`。
+
+关键缺口:
+
+1. `APIGNewApiBillingMap` 只到模块/模型级,无法表达每个 `proxyPath` 的单价、计费单位和生效版本。
+2. `pathQuota` 可以改变实际扣除额度,但日志中的 `price_cny` 仍来自模块级映射;现有看板在 `per_call` 模式下优先使用 `price_cny`,可能出现路径级扣额与展示金额不一致。
+3. `logs` 没有报告和任务关联字段,历史任务只能按用户和时间窗口猜测,不能形成可审计的单报告账单。
+4. 经营看板使用管理员看板密钥,不能直接作为普通账户余额页或 AI 的查询接口。
+5. 现有 `/api/fmode/newapi/user` 返回余额、充值和订阅,不返回模块、任务、接口用量。
+6. `r:` Session 分支仍扣减旧 `APIGAuth.count`,未进入 NewAPI 通用余额和日志,和本需求的新模式冲突。
+7. 三个 VOC 网关对业务成功、缓存和扣费时机的处理不完全一致,必须统一为同一条幂等计费流水线。
+8. 当前人民币与 quota 的换算依赖 NewAPI `USDExchangeRate` 和每单位 500,000 quota;路径 quota 与日志人民币金额没有共享同一个解析结果。
+
+### 3.3 Fmode Studio 余额页
+
+相关文件:
+
+- `projects/fmode-studio/src/components/balance-modal.component.ts`
+- `projects/fmode-studio/src/modules/launcher/balance-page.component.ts`
+- `projects/fmode-studio/src/modules/launcher/auth-guard.service.ts`
+- `projects/fmode-studio/src/app/app.routes.ts`
+- `projects/fmode-studio/src/modules/launcher/fmode-home.component.ts`
+
+已具备:
+
+- 通过会话身份查询 Fmode API 余额、已用额度、充值和订阅。
+- 钱包充值、额度划转、套餐购买和支付二维码。
+- 支付状态轮询、支付成功提示和余额刷新。
+- Tokenized Balance 链接和 VOC 安装入口可以自动打开余额弹窗。
+- 独立 `/balance` 页面已经支持 `?token=`,调用 `Parse.User.become()` 后立即从地址栏移除 Token。
+- 独立余额页已经按 `/api/fmode/recharge/order` → `pay_code2` → `order_status2` 完成建单、二维码、轮询和到账刷新。
+
+以上能力已在 `fmode-studio` 远端 `origin/master` 的 `97c64bc`、`d5ae86f`、`e9ade0b` 及 `future-server` 的 `af2541aa` 中核实。本需求复用现有充值中心,不重做支付页面和订单逻辑。
+
+缺少:
+
+- 日、周、月消费趋势。
+- 大模型、VOC 社媒、国内电商、海外电商等模块拆分。
+- 任务/报告账单和接口明细。
+- 计费规则页和 Agent 成本测算所需的数据查询。
+- 预算预警、余额可支撑任务量提示。
+- Agent 模型调用自身收到 402 后,由 Studio/Agent 运行时直接弹出充值入口的兜底;该动作不能依赖已经停止响应的模型继续调用工具。
+
+### 3.4 原 VOC 经营看板
+
+相关文件:
+
+- `voc-profit-web/client/src/app/api.service.ts`
+- `voc-profit-web/client/src/app/dashboard.component.*`
+- `future-server/api/api-ncloud/profit/routes.js`
+- `future-server/api/api-ncloud/profit/service/revenue.service.js`
+
+可复用能力:
+
+- `module-forwarding` 的模块、端点、用户、最近调用聚合。
+- `upstream-cost` 的收入、上游成本、缓存率和利润分析。
+- 用户排行、时间趋势和充值流水。
+
+复用原则:
+
+- 复用服务层的 SQL 口径和聚合思路。
+- 管理员经营视图继续留在 `voc-profit-web`。
+- 面向个人/企业账户的新接口必须按当前身份限定 `user_id`,不能复用管理员全量返回。
+
+## 4. 核心设计决策
+
+### 4.1 三本账分开
+
+| 账本 | 含义 | 面向对象 |
+| --- | --- | --- |
+| 用户消费账 | 用户实际被扣除的额度与人民币金额 | 用户、AI、客服 |
+| 上游成本账 | 平台向数据供应商和模型供应商支付的成本 | 运营、财务、产品 |
+| 现金与充值账 | 用户充值、钱包划转、订阅购买 | 用户、财务 |
+
+本期余额页重点展示用户消费账和现金充值账;上游成本与毛利继续留在管理员经营看板,避免把内部采购价暴露给普通用户。
+
+### 4.2 计费规则只有一个权威来源
+
+后端计费引擎是单价和实扣的权威来源。技能目录可以缓存公开价目快照,但不能自行维护另一套单价。Agent 通过接口读取当前价格后,按照自己生成的业务调用计划完成报价计算;服务端不替 Agent 决定报告方案和调用次数。
+
+扣费、日志金额、余额页和 Agent 报价所读取的单价必须来自同一个规则解析函数:
+
+```text
+resolveBillingRule(model, method, path, effectiveAt)
+  -> billingMode
+  -> unitName
+  -> unitPriceCny
+  -> quotaPerUnit
+  -> pricingVersion
+  -> cachePolicy
+```
+
+### 4.3 估算和实付严格区分
+
+- `estimatedCostCny`:执行前根据计划计算。
+- `budgetLimitCny`:Agent 执行时遵守的用户预算上限,不冻结、不预扣 NewAPI 余额。
+- `actualCostCny`:成功扣费日志汇总。
+- `unpricedCostCny`:缺少有效价目、只能按兜底规则估算的部分。
+
+前端和 AI 不得把估算值显示为已消费。
+
+### 4.4 任务追踪优先,不用时间窗口猜账
+
+每次复杂报告执行由 Agent 本地生成 `usageTraceId`,每份输出可生成 `reportId`。所有 VOC 请求和可关联的大模型请求都携带同一 `usageTraceId`。
+
+`usageTraceId` 只是用量日志标签,用于精确拆分不同报告的接口频次和消费。它不是订单号,不创建独立余额,不冻结资金,也不改变扣费方式;所有调用仍然实时扣除同一用户的 NewAPI 通用余额。
+
+历史任务没有这些字段时可以提供“按时间窗口推算”,但必须标记为 `reconstructed`,不能标记为精确账单。
+
+### 4.5 Agent 调起现有充值中心,用户只完成支付
+
+VOC 技能余额不足时,不让用户重新登录和寻找充值入口,由仍可运行的 Agent 完成充值前测算。默认优先返回二维码,自助链接作为另一种方式:
+
+1. 根据任务剩余计划计算差额和建议充值金额。
+2. 默认复用 `/api/fmode/recharge/order` 和 `pay_code2`,由 Agent 组装现有支付请求参数并直接返回支付二维码。
+3. 另一种方式是拼接 `https://app.fmode.cn/dev/studio/balance/?token=USER_TOKEN`,用户进入现有 Balance 页面自行选择金额。
+4. 支持网页跳转的客户端可以直接打开该链接;聊天或企微场景返回可点击链接。
+5. 现有 Balance 页面完成订单、支付轮询、到账划拨和余额刷新。
+6. Agent 再次查询余额,余额充足后从本地检查点继续未完成批次。
+
+充值增加的是用户 NewAPI 通用余额,不是购买某个任务。充值订单不与 `usageTraceId` 做财务绑定;追踪标识只用于执行前后测算和实际账单归集。
+
+### 4.6 两类余额不足必须分层处理
+
+| 余额不足类型 | 发生位置 | 谁还能执行 | 正确处理 |
+| --- | --- | --- | --- |
+| Agent 模型账户余额不足 | 大模型/NewAPI 模型调用返回 402 | Agent 已经不能继续生成回复或调用技能 | 由 Studio、OpenClaw 或 Agent 宿主运行时拦截 402,直接展示现有 Balance 链接/充值卡片;同时在耗尽前做预警 |
+| VOC 技能数据余额不足 | VOC 网关或 Skill 调用返回 `needs_recharge` | Agent 模型仍可工作 | Skill 强制计算剩余成本,默认组装支付请求返回二维码,也提供带 Token 的 Balance 链接 |
+
+Skill 规则只能解决第二类。第一类不能设计成“让 Agent 再调用一个支付工具”,因为模型余额耗尽后这一步已经没有执行条件;必须由不依赖模型推理的宿主 UI/运行时完成兜底。
+
+### 4.7 采集多媒体必须持久化
+
+所有 VOC 采集结果中出现的图片、视频、音频及封面文件必须进入统一媒体流水线:
+
+1. 发现远程媒体 URL 后立即下载到本任务本地目录。
+2. 文件按内容摘要命名并去重,写入 `outputs/<usageTraceId>/assets/`。
+3. 标准化数据和报告中的展示 URL 一律使用本地相对路径。
+4. 原始平台 URL 只保留在媒体清单的 `sourceUrl` 中用于溯源,不作为报告主要展示地址。
+5. 下载失败必须记录状态并明确降级,不得悄悄继续引用易失效的远程链接。
+6. 本技能包不读取 Storage 凭证、不申请预签名 URL、不调用线上上传接口。
+
+## 5. 统一数据模型
+
+### 5.1 路径级价目表 `APIGEndpointPricing`
+
+不建议继续把大量路径规则塞进运行时 `pathQuota` 配置。新增可查询、可版本化的数据表:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `objectId` | string | 主键 |
+| `model` | string | `voc-social` / `voc-e-commerce` / `voc-ecom` |
+| `channel` | string | `social` / `ecommerce` / `overseas` |
+| `endpointId` | string | 技能清单中的稳定 ID |
+| `method` | string | HTTP 方法 |
+| `proxyPath` | string | 规范化路径,不含 query |
+| `billingMode` | string | `per_call` / `per_page` / `per_item` / `per_minute` / `per_token` |
+| `unitName` | string | 次、页、商品、账号、分钟、千 token 等 |
+| `unitPriceCny` | number | 对用户展示的单位价格 |
+| `quotaPerUnit` | integer | 实际扣除额度 |
+| `cachePolicy` | string | `same_as_upstream` / `discounted` / `free` |
+| `cacheQuotaPerUnit` | integer/null | 缓存命中的扣额 |
+| `pricingVersion` | string | 如 `2026-09-01.1` |
+| `effectiveFrom` | date | 生效时间 |
+| `effectiveTo` | date/null | 失效时间 |
+| `enabled` | boolean | 是否启用 |
+| `source` | string | 价格来源和校准方式 |
+| `metadata` | object | 标题、说明、限制等非敏感信息 |
+
+匹配优先级:
+
+1. `model + method + proxyPath` 精确匹配。
+2. `model + endpointId` 匹配。
+3. 回退 `APIGNewApiBillingMap` 模块级规则。
+4. 缺少任何有效规则时,预估接口返回 `unpriced`;生产扣费是否允许兜底由服务配置决定。
+
+### 5.2 用量追踪上下文 `UsageTrace`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `usageTraceId` | UUID | Agent 本地生成的一次报告执行标识,不是支付或扣款 ID |
+| `clientId` | string/null | 安装实例或 Agent 标识,自报标签 |
+| `skillId` | string | 如 `voc-api-catalog` |
+| `workflowType` | string | 如 `xhs_account_monitor`、`tmall_listing_audit` |
+| `title` | string | 用户可读任务名 |
+| `reportId` | string/null | 报告或输出标识 |
+| `stepId` | string/null | 当前采集步骤 |
+| `budgetLimitCny` | number/null | Agent 本地预算上限,不冻结余额 |
+| `estimateLowCny` | number | 低值 |
+| `estimateExpectedCny` | number | 预期值 |
+| `estimateHighCny` | number | 高值 |
+| `pricingVersion` | string | 预估时价目版本 |
+| `plan` | object | Agent 本地保存的结构化调用计划,不含密钥 |
+
+首期不要求服务端新增 `UsageTask` 表或任务 CRUD API。追踪上下文写入本地检查点和 NewAPI `logs.other` 即可;当前用户用量接口按 `usageTraceId` 聚合实际日志。
+
+### 5.3 报告本地元数据 `UsageReport`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `reportId` | UUID | 报告 ID |
+| `usageTraceId` | UUID | 所属执行追踪标识 |
+| `reportType` | string | 日报、排行、Listing 诊断等 |
+| `title` | string | 报告名称 |
+| `artifactUrl` | string/null | 交付地址,可选 |
+| `scope` | object | 账号数、商品数、日期等 |
+| `status` | string | `partial/completed/failed` |
+
+### 5.4 充值数据沿用现有账务模型
+
+不新增与报告绑定的 `BillingPaymentSession`。充值继续使用现有支付订单、钱包账本、NewAPI `top_ups` 和余额刷新逻辑。Agent 只提供建议金额和访问方式,不建立一次性任务支付关系。
+
+### 5.5 媒体清单 `media-manifest.json`
+
+每次报告执行生成 `outputs/<usageTraceId>/assets/media-manifest.json`:
+
+```json
+{
+  "usageTraceId": "USAGE_TRACE_ID",
+  "items": [
+    {
+      "mediaId": "MEDIA_ID",
+      "entityType": "note",
+      "entityId": "ENTITY_ID",
+      "mediaType": "image",
+      "sourceUrl": "SOURCE_URL",
+      "localPath": "outputs/USAGE_TRACE_ID/assets/xiaohongshu/ENTITY_ID/HASH.jpg",
+      "cloudUrl": null,
+      "effectiveUrl": "outputs/USAGE_TRACE_ID/assets/xiaohongshu/ENTITY_ID/HASH.jpg",
+      "mimeType": "image/jpeg",
+      "bytes": 123456,
+      "sha256": "HASH",
+      "cacheStatus": "cached",
+      "uploadStatus": "disabled_local_only"
+    }
+  ]
+}
+```
+
+`effectiveUrl` 固定取 `localPath`,`cloudUrl` 固定为 `null`,`uploadStatus` 固定为 `disabled_local_only`。报告生成器只能消费 `effectiveUrl`。
+
+### 5.6 NewAPI `logs.other` 扩展字段
+
+继续使用现有 `logs` 作为实际扣费事实表,在 `other` 中增加:
+
+```json
+{
+  "billing_source": "apig",
+  "service": "voc-social-api",
+  "request_path": "TARGET_PATH",
+  "request_method": "GET",
+  "source": "upstream",
+  "endpoint_id": "xiaohongshu.get_user_note_list_v4",
+  "usage_trace_id": "USAGE_TRACE_ID",
+  "report_id": "REPORT_ID",
+  "skill_id": "voc-api-catalog",
+  "workflow_type": "xhs_account_monitor",
+  "step_id": "collect_account_notes",
+  "client_id": "CLIENT_ID",
+  "billing_mode": "per_call",
+  "unit_name": "call",
+  "unit_count": 1,
+  "unit_price_cny": 0.1,
+  "actual_cost_cny": 0.1,
+  "pricing_version": "2026-09-01.1",
+  "cache_policy": "same_as_upstream"
+}
+```
+
+约束:
+
+- `actual_cost_cny` 必须由本次实际扣除 `quota` 和已解析规则生成,不再从模块级 `price_cny` 反推。
+- 用户传入的追踪字段必须限制长度和字符集。
+- `userId/tokenId` 只能从鉴权结果得到,不能接受客户端覆盖。
+- `clientId` 是展示维度,不作为强权限边界;同一账户共享同一密钥时,Agent 归属只能视为自报标签。
+
+## 6. 后端 API 契约
+
+统一前缀建议使用 `/api/fmode/billing`。同时支持两种身份:
+
+- Studio:请求体或 Cookie 中的会话身份。
+- Claude Code/MCP:`Authorization: Bearer <NewAPI key>`。
+
+所有返回只包含当前账户数据。
+
+接口数量边界:计费、报价数据准备和用户用量只新增下面一个 `billing/query` 聚合接口,通过 `include` 和筛选入参一次返回 Agent 当前所需数据;Agent 在本地生成计划并计算报价。充值复用现有 3 个调用,不新增支付接口。媒体本轮只落本地,不调用 Storage 接口。
+
+### 6.1 当前用户计费统一查询
+
+新增一个聚合接口,同时服务 Agent 报价和 Balance 页面:
+
+```http
+POST /api/fmode/billing/query
+Authorization: Bearer USER_TOKEN
+Content-Type: application/json
+```
+
+Agent 执行前只查询余额和候选接口价格:
+
+```json
+{
+  "include": ["balance", "pricing"],
+  "pricingFilter": {
+    "channels": ["social"],
+    "endpointIds": [
+      "xiaohongshu.search_user_v2",
+      "xiaohongshu.get_user_note_list_v4",
+      "xiaohongshu.get_note_comments_v3"
+    ]
+  }
+}
+```
+
+以上是 Agent/MCP 内部结构,不是要求用户填写的表单。用户继续用自然语言描述任务,Agent 负责抽取范围、补默认值并生成候选调用计划。
+
+Balance 页面查询当前用户用量:
+
+```json
+{
+  "include": ["balance", "pricing", "usage"],
+  "pricingFilter": {
+    "channels": ["social", "ecommerce", "overseas"]
+  },
+  "usageFilter": {
+    "from": "2026-08-30",
+    "to": "2026-09-01",
+    "usageTraceId": "USAGE_TRACE_ID",
+    "groupBy": ["module", "endpoint", "usageTraceId"],
+    "page": 1,
+    "pageSize": 100
+  }
+}
+```
+
+统一响应:
+
+```json
+{
+  "code": 200,
+  "data": {
+    "balance": {
+      "availableCny": 30,
+      "usedCny": 120
+    },
+    "pricing": {
+      "version": "2026-09-01.1",
+      "currency": "CNY",
+      "items": [
+        {
+          "endpointId": "xiaohongshu.get_user_note_list_v4",
+          "channel": "social",
+          "method": "GET",
+          "proxyPath": "TARGET_PATH",
+          "billingMode": "per_call",
+          "unitName": "次",
+          "unitPriceCny": 0.1,
+          "quotaPerUnit": 7353,
+          "cachePolicy": "same_as_upstream"
+        }
+      ]
+    },
+    "usage": {
+      "totals": {
+        "calls": 280,
+        "billedCalls": 280,
+        "quota": 2058840,
+        "costCny": 28
+      },
+      "groups": [],
+      "quality": "exact"
+    }
+  }
+}
+```
+
+接口约束:
+
+- 服务端只返回当前用户的余额、筛选后的有效价目和实际用量,不生成最终报价。
+- Agent 根据业务范围自行生成调用计划,并计算低值、预期值和高值。
+- `include`、`groupBy`、时间范围、接口数量和分页大小均使用服务端白名单和上限。
+- 用量查询始终强制附加当前 `user_id`,`usageTraceId` 只是附加筛选条件。
+- `quality=exact` 表示日志有追踪标识和实际金额;历史数据继续标记 `mixed/reconstructed`。
+
+### 6.2 Agent 调起现有充值能力
+
+充值中心已有余额展示、金额选择、二维码、支付轮询和到账刷新能力,本需求不重新实现这套支付链路。提供两种方式:
+
+方式一,用户自助选择金额:
+
+```text
+https://app.fmode.cn/dev/studio/balance/?token=USER_TOKEN
+```
+
+独立 Balance 页已经消费 `token` 完成免登录识别,并在识别后从地址栏移除。用户进入页面后自行选择充值金额。
+
+方式二,默认由 Agent 直接返回支付二维码:
+
+```text
+POST /api/fmode/recharge/order
+POST /parse/functions/pay_code2
+POST /parse/functions/order_status2
+```
+
+Agent 根据建议充值金额生成 `tradeNo`,调用现有 `recharge/order` 创建或复用 `AccountLog`,再调用 `pay_code2` 取得 `code_url` 并渲染二维码。需要等待到账时复用 `order_status2`;这三步均为现有链路,不新增 billing 支付接口。
+
+统一交互规则:
+
+- 默认优先由 Agent 返回二维码,减少用户再次选择和操作。
+- 用户希望自行选择金额,或当前环境不适合展示二维码时,返回带 Token 的 Balance 链接。
+- 支持打开浏览器的 Agent 可以直接打开该链接;其他聊天环境返回可点击链接。
+- 页面继续使用现有支付二维码、支付按钮、订单查询和余额刷新逻辑。
+- 充值金额进入用户 NewAPI 通用余额,与 `usageTraceId`、报告和单次任务不做财务绑定。
+- 到账后 Agent 再调用 `/api/fmode/billing/query` 查询最新余额,并从本地检查点继续。
+
+### 6.3 本地媒体持久化
+
+本轮明确采用本地优先且线上禁用的策略,不新增或调用 Storage 上传接口。媒体处理流程如下:
+
+1. 技能从采集响应中发现图片、视频、音频和封面 URL。
+2. 立即下载到 `outputs/<usageTraceId>/assets/<platform>/<entityId>/`,按 SHA-256 去重。
+3. `media-manifest.json` 记录 `sourceUrl`、`localPath`、摘要、MIME、大小和缓存状态。
+4. 报告与标准化数据的 `effectiveUrl` 一律使用本地相对路径。
+5. `cloudUrl` 固定为 `null`,`uploadStatus` 固定为 `disabled_local_only`。
+6. 不请求 `storage/profile`、预签名 URL 或上传完成接口,避免消耗线上 Storage 资源。
+
+## 7. 计费引擎改造
+
+### 7.1 `newapi-metering.ts`
+
+把当前 `resolveQuota()` 升级为 `resolveBillingRule()`,返回完整规则:
+
+```ts
+type ResolvedBillingRule = {
+  quota: number;
+  billingMode: string;
+  unitName: string;
+  unitCount: number;
+  unitPriceCny: number;
+  actualCostCny: number;
+  pricingVersion: string;
+  cachePolicy: string;
+  matchedBy: "endpoint" | "module" | "fallback";
+};
+```
+
+`chargeNewApiUsage()` 返回值增加:
+
+```ts
+{
+  quota,
+  costCny,
+  pricingVersion,
+  userId,
+  tokenId,
+  channelId,
+  logId
+}
+```
+
+并将解析后的真实金额写入日志。这样余额页不再依赖可能失真的模块级 `price_cny`。
+
+### 7.2 三个 VOC 网关模块
+
+对 `voc-social-api`、`voc-e-commerce`、`voc-ecom-api` 做同样改造:
+
+1. 从白名单请求头读取任务上下文。
+2. 规范化 `proxyPath` 并映射 `endpointId`。
+3. `assertNewApiQuotaAvailable()` 与 `chargeNewApiUsage()` 使用同一条已解析规则。
+4. 成功响应增加非敏感计费头:
+
+```http
+X-Fmode-Usage-Quota: 7353
+X-Fmode-Usage-Cny: 0.100000
+X-Fmode-Pricing-Version: 2026-09-01.1
+X-Fmode-Usage-Source: upstream
+```
+
+5. JSON 响应暂不强制改变,避免破坏旧客户端;MCP 从响应头读取实际用量。
+
+### 7.3 幂等与重试
+
+- 技能为每个逻辑调用生成稳定 `requestId`。
+- 同一 `requestId + model + fingerprint` 只扣一次。
+- 同一 `requestId` 参数变化继续返回 409,防止错误复用。
+- 5xx 重试沿用同一 `requestId`。
+- 业务失败不扣费;成功的缓存命中按 `cachePolicy` 扣费。
+- 用量统计分别展示尝试次数和计费次数,避免把网络重试误认为多次收费。
+
+## 8. 技能包改造
+
+### 8.1 新增核心模块
+
+```text
+mcp/src/core/billing-client.js
+mcp/src/core/usage-context.js
+mcp/src/core/budget-guard.js
+mcp/src/core/recharge-client.js
+mcp/src/features/voc-cost/workflow-planner.js
+mcp/src/features/voc-cost/estimate-report.js
+mcp/src/features/media/media-pipeline.js
+mcp/src/features/media/media-manifest.js
+mcp/src/tools/voc-cost-estimate-run.js
+mcp/src/tools/voc-usage-report-run.js
+skills/voc-cost-controller/SKILL.md
+skills/voc-cost-controller/references/workflow-cost-models.md
+```
+
+职责:
+
+- `billing-client.js`:按筛选条件调用统一 `/api/fmode/billing/query`,读取余额、价目和实际用量。
+- `usage-context.js`:本地创建和传递 `usageTraceId/reportId/stepId`,不创建服务端任务或支付记录。
+- `budget-guard.js`:Agent 根据当前余额、本地预算和实际用量,在执行前及批次间做检查。
+- `recharge-client.js`:计算差额后默认复用现有建单和 `pay_code2` 返回二维码;同时提供带 Token 的 Balance 自助链接。
+- `workflow-planner.js`:把自然语言任务转换成确定性的接口调用计划。
+- `estimate-report.js`:生成用户可读的三档报价和降本说明。
+- `media-pipeline.js`:发现、下载、去重、校验、写入本地并替换多媒体 URL。
+- `media-manifest.js`:维护媒体原地址、本地地址和处理状态,不保存线上访问地址。
+
+### 8.2 新增 MCP 工具
+
+#### `voc_cost_estimate`
+
+输入保持业务化:
+
+```json
+{
+  "task": "监听 280 个小红书账号并生成门店排行榜",
+  "workflowType": "xhs_account_monitor",
+  "accountCount": 280,
+  "unresolvedAccountCount": 105,
+  "notesPerAccount": 5,
+  "commentPagesPerNote": 0,
+  "mode": "standard",
+  "budgetCny": 100
+}
+```
+
+#### `voc_usage_report`
+
+输入 `usageTraceId/reportId` 或日期范围,通过同一个 billing query 接口取得实际用量,返回可直接发送给用户的成本报告。
+
+首期只新增以上两个用户可见 MCP 工具。`voc_cost_estimate` 内部完成业务计划生成、价目查询、报价和余额比较;预算检查和充值入口作为工作流内部 helper,不再拆成多组 MCP 工具。
+
+### 8.3 扩展现有工具输出
+
+`voc_api_call` 及专用采集工具的标准结果增加:
+
+```json
+{
+  "usage": {
+    "usageTraceId": "USAGE_TRACE_ID",
+    "reportId": "REPORT_ID",
+    "endpointId": "ENDPOINT_ID",
+    "calls": 1,
+    "billedCalls": 1,
+    "quota": 7353,
+    "costCny": 0.1,
+    "pricingVersion": "2026-09-01.1"
+  }
+}
+```
+
+整个工作流结束时,`summary` 增加:
+
+```json
+{
+  "estimatedCostCny": 42.8,
+  "actualCostCny": 40.6,
+  "remainingBalanceCny": 59.4,
+  "usageReportUrl": "URL"
+}
+```
+
+### 8.4 技能行为规则
+
+复杂任务满足任一条件时必须先估算:
+
+- 账号数、商品数或关键词数大于 20。
+- 预计接口调用超过 50 次。
+- 用户明确询问成本、余额或性价比。
+- 定时任务首次创建或采集范围显著扩大。
+
+默认策略:
+
+- 首轮先给经济版、标准版、完整版。
+- 默认执行标准版,但预计费用超过可配置阈值时等待用户确认。
+- 有明确 `budgetCny` 时把它作为硬上限。
+- VOC 数据余额不足时先保存检查点,再由 Agent 计算差额;默认组装现有支付请求返回二维码,同时提供带 Token 的 Balance 自助链接。
+- Agent 模型余额不足不进入 Skill 流程,由 Studio/OpenClaw/Agent 宿主运行时拦截模型 402 并直接显示充值入口。
+- 现有 Balance 页面完成支付和余额刷新;当前 Agent 会话继续查询余额并从检查点恢复,不要求用户重新发起采集。
+- 如果用户暂不支付,再提供缩小范围或基于已有结果出部分报告的选项。
+- 报告必须附成本摘要,不附密钥和原始鉴权错误。
+
+### 8.5 多媒体强制处理规则
+
+所有 VOC 采集工具和报告工作流必须遵守:
+
+1. 递归识别响应中的图片、视频、音频、封面、买家秀和详情长图 URL。
+2. 在继续下一采集批次前,把新发现的媒体写入 `media-manifest.json`。
+3. 下载到 `outputs/<usageTraceId>/assets/<platform>/<entityId>/`,以内容摘要命名并去重。
+4. 校验 HTTP 状态、实际 MIME、文件大小和 SHA-256;不信任仅由扩展名声明的类型。
+5. 标准化 JSON 保留 `sourceUrl/localPath/cloudUrl/effectiveUrl`,其中 `cloudUrl` 固定为 `null`,`effectiveUrl=localPath`。
+6. 报告只能引用 `effectiveUrl`,保证离线浏览且不产生线上 Storage 流量。
+8. 带时效签名的上游地址优先下载,不能延迟到报告生成阶段。
+9. 单文件失败自动重试;最终失败时记录原因并在报告中显示“媒体缓存失败”,不回退为远程热链。
+10. 任务结束前执行媒体完整性检查;存在未处理媒体时任务状态为 `partial`,不得声称完整交付。
+
+现有 `cacheEvidenceAssets()` 可以作为图片下载的第一版基础,但需要扩展到视频、音频、详情长图、去重、断点续传和 URL 重写;线上 Storage 上传不在本轮范围内。
+
+## 9. 通用成本模型与首批报告样例
+
+成本能力必须对所有 VOC Skill、任意报告和任意复杂采集任务通用,不限定小红书和天猫。通用过程是“Skill 生成步骤和计费单位计划 → billing query 按 endpoint 筛选返回价目 → Agent 计算三档报价 → 实际调用按 `usageTraceId` 归集”。
+
+下面两类报告只是首批可回放、可验收的基准样例,用于验证通用模型,不是功能范围边界。后续其他平台和工作流只需提供步骤规划适配器,不新增另一套计费接口。单价必须运行时从后端读取,文档中的 `P(endpoint)` 表示该接口当前生效单价。
+
+### 9.1 小红书账号监听与门店排行
+
+输入变量:
+
+| 变量 | 含义 |
+| --- | --- |
+| `A` | 登记账号数,本场景为 280 |
+| `D` | 已有直链、可直接取用户 ID 的账号数,本场景为 179 |
+| `S` | 需要搜索匹配的账号数,本场景为 105;与 `A-D` 的差异必须作为数据质量提示 |
+| `C` | 本轮实际覆盖账号数,已知历史执行在 147 个时停止 |
+| `N` | 每账号读取的笔记页数 |
+| `K` | 每条入选笔记读取的评论页数 |
+| `R` | 每账号进入详情/评论的笔记数 |
+
+基础调用公式:
+
+```text
+账号解析费用
+= S * P(xiaohongshu.search_user_v2)
+
+账号笔记费用
+= A * N * P(xiaohongshu.get_user_note_list_v4)
+
+笔记详情费用(按需)
+= A * R * P(xiaohongshu.get_note_detail_vN)
+
+评论费用(按需)
+= A * R * K * P(xiaohongshu.get_note_comment_vN)
+
+报告生成费用
+= LLM(inputTokens, outputTokens, cacheTokens, modelPrice)
+```
+
+三档建议:
+
+| 模式 | 采集策略 | 适用场景 |
+| --- | --- | --- |
+| 经济版 | 只搜索未解析账号;每账号 1 页笔记;不逐条拉评论;用已有互动字段排行 | 日报、赛事榜单 |
+| 标准版 | 每账号 1 页笔记;只对 Top/新增笔记拉详情;仅异常样本拉评论 | 周报、运营诊断 |
+| 完整版 | 多页笔记;入选笔记全量详情和评论;补用户画像 | 月报、深度研究 |
+
+历史报告回算说明:
+
+- 当前日志没有 `usageTraceId/reportId`,无法把同一账户同一时段的其他调用排除。
+- 第一版只能用报告运行起止时间、用户 ID、模型名和路径做 `reconstructed` 回算。
+- 新版上线后,同一报告执行使用同一 `usageTraceId`,即可精确回答覆盖 147 个账号前花了多少、剩余 133 个还需多少。
+
+### 9.2 天猫店铺商品 Listing 诊断
+
+输入变量:
+
+| 变量 | 含义 |
+| --- | --- |
+| `G` | 店铺商品列表页数,本场景已知为 5 |
+| `A` | 店铺在售商品数,本场景为 134 |
+| `S` | 进入完整诊断的代表商品数,已交付报告为 8 |
+| `K` | 每商品评论页数 |
+| `V` | 每商品调用的详情版本数 |
+
+推荐调用公式:
+
+```text
+店铺商品清单
+= G * P(taobao.get_shop_item_list_v4)
+
+全店轻量字段
+= A * P(最小可用详情接口)
+
+代表商品价格/SKU/主图
+= S * P(taobao.get_item_detail_v4)
+
+代表商品销量/长图/视频/属性
+= S * P(taobao.get_item_detail_v3)
+
+代表商品评价
+= S * K * P(taobao.get_item_comment_v3)
+
+报告生成
+= LLM(inputTokens, outputTokens, imageTokens, modelPrice)
+```
+
+三档建议:
+
+| 模式 | 采集策略 | 价值与成本 |
+| --- | --- | --- |
+| 经济版 | 5 页店铺清单 + 按销量/新品/价格分层抽 8 个代表 SKU | 最适合快速体检,避免 134 个商品全部深采 |
+| 标准版 | 134 个商品拉轻量字段,Top 20 做完整详情与评论 | 能兼顾全店覆盖和诊断深度 |
+| 完整版 | 134 个商品全部拉价格、详情、素材、评价并逐页打分 | 成本最高,仅在需要逐 SKU 改版清单时使用 |
+
+接口选择规则:
+
+- 不为“保险”并发调用多个详情版本。
+- 先使用当前技能文档确认的可用版本;只有字段缺失时才调用补充版本。
+- 已知不稳定或不支持的版本不进入默认计划。
+- 失败且未计费的探测也要计入“尝试次数”,但不能计入“实际消费”。
+- 详情长图和评论只有在评分模型实际使用时才采集。
+
+### 9.3 成本价值判断
+
+每个步骤增加下列业务字段:
+
+```json
+{
+  "required": true,
+  "valueScore": 5,
+  "valueLabel": "门店排行核心字段",
+  "omissionImpact": "不再能计算账号活跃率",
+  "fallback": "使用上次快照"
+}
+```
+
+成本报告按以下指标排序:
+
+```text
+costShare = stepCost / taskCost
+valuePerCny = valueScore / stepCost
+wasteCandidate = !required && costShare >= 0.1 && valueScore <= 2
+```
+
+`valueScore` 是业务配置和人工校准结果,不应由接口价格自动推断。AI 可以提出建议,但删除采集项前必须说明信息损失。
+
+## 10. 大模型用量归集
+
+VOC 数据费和报告生成的大模型费必须分开显示。
+
+### 10.1 P0
+
+- 精确统计有追踪标识的 VOC API 调用。
+- 大模型用量按同一用户、任务起止时间给出 `reconstructed` 估算。
+- 无法关联的模型调用列为“未归集”,不强行塞入某份报告。
+
+### 10.2 P1
+
+- Agent/Claude 运行时创建统一 `usageTraceId`。
+- MCP 请求和模型网关请求都传递该 `usageTraceId`。
+- AIGate 写入模型调用的输入、输出、缓存 token、金额和任务上下文。
+- 用量查询将 `billing_source=apig` 与模型日志按 `usageTraceId` 合并。
+
+最终任务账单:
+
+```json
+{
+  "dataCollectionCny": 36.4,
+  "modelGenerationCny": 8.2,
+  "otherModuleCny": 0,
+  "totalCny": 44.6
+}
+```
+
+## 11. Balance 页面信息架构
+
+在现有余额弹窗基础上补充用户成本测算和用量展示。充值中心已有 Token 免登录识别、余额展示、金额选择、支付二维码/按钮、支付轮询和到账刷新,本需求复用并回归验证,不另建支付中心。
+
+建议整理为四个页签:
+
+1. **总览**:可用余额、今日/近 7 日/本月消费、预计可支撑任务量、预算预警。
+2. **用量**:模块占比、每日趋势、任务/报告列表、接口消耗排行。
+3. **计费规则**:每个模块和接口的计费单位、当前单价、生效时间、缓存规则。
+4. **充值与套餐**:保持现有钱包、二维码、划转、订阅、Token 免登录和到账刷新逻辑;自助链接内由用户选择金额,Agent 直出二维码时使用建议金额。
+
+### 11.1 总览
+
+展示:
+
+- Fmode API 当前余额。
+- 本月已消费及环比。
+- 大模型 / VOC 社媒 / 国内电商 / 海外电商 / 其他模块。
+- 最近 5 个报告执行记录及状态。
+- “按最近 7 天速度预计可用 N 天”。
+- 有待执行任务时显示“完成该任务预计还需 ¥X”。
+- 从 Agent 返回的 Token 链接进入时不显示登录步骤和无关设置,用户直接选择金额并支付;Agent 默认二维码模式则在聊天中直接展示按建议金额创建的支付码。
+- 支付成功后沿用现有余额刷新;当前 Agent 会话重新查询余额并从本地检查点继续。
+
+### 11.2 用量
+
+筛选:
+
+- 今日、7 天、30 天、自然月、自定义。
+- 模块、技能、任务、报告、接口。
+- 计费来源、缓存/上游、成功/失败。
+
+明细字段:
+
+- 时间、任务、报告、模块、接口、步骤。
+- 调用次数、计费次数、缓存次数。
+- 单价、额度、人民币金额、价目版本。
+- 数据质量 `exact/mixed/reconstructed`。
+
+支持 CSV 导出,但导出中不包含 token、请求体、原始评论或商品数据。
+
+### 11.3 成本优化视图
+
+默认展示 Pareto 排行:
+
+- 消耗最高的前 10 个接口。
+- 占总费用比例。
+- 对应报告指标。
+- 可关闭/降采样的建议。
+- 预计节省金额与影响范围。
+
+例如:
+
+```text
+逐条评论采集占本任务 43%,但门店排行榜只使用发帖数和互动总量。
+切换为“只采 Top 20 账号评论”预计节省 ¥X,门店排行不受影响,评论洞察覆盖率下降。
+```
+
+## 12. 管理员经营看板改造
+
+`voc-profit-web` 保留经营角色,并增加:
+
+- 价目版本和未定价接口告警。
+- 实扣额度与展示人民币金额一致性校验。
+- 按 `usageTraceId/workflowType/skillId` 聚合。
+- 企业账户、token、安装实例的消费拆分。
+- 失败率、缓存率、单位有效样本成本。
+- 用户收入、上游采购成本和毛利继续分列。
+
+现有 `module-forwarding` 可作为实现底稿,但需要:
+
+1. 使用 `actual_cost_cny` 替代旧 `LOG_CNY_SQL` 的模块价回退。
+2. 新增当前用户版本的聚合函数,强制 `WHERE user_id = $currentUserId`。
+3. 增加执行追踪、报告、步骤分组。
+4. 对历史记录保留兼容计算并标记 `mixed`。
+
+管理员任务级诊断已落地为现有经营看板的一条管理员路由:
+
+```text
+GET /api/profit/api/usage-diagnostics?days=2&limit=200
+```
+
+可选筛选参数为 `usageTraceId`、`reportId`、`workflowType`。返回内容包括任务汇总、接口调用频次、计费 quota、实际消费、缓存命中、上游调用以及高成本低价值候选和预计节省金额。该路由继续使用看板管理员鉴权,不向普通 Balance 用户开放;用户侧仍只调用 `POST /api/fmode/billing/query`。
+
+## 13. 分仓开发清单
+
+### 13.1 `future-server`
+
+P0:
+
+- 新增 `APIGEndpointPricing` 表和迁移/seed。
+- 抽取统一 `resolveBillingRule()`。
+- 修正路径级扣额与日志金额不一致。
+- 扩展 `logs.other` 追踪和实际金额字段。
+- 增加统一 `/api/fmode/billing/query`,通过入参筛选返回当前用户余额、价目和用量。
+- 复用现有 `/api/fmode/recharge/order`、`pay_code2`、`order_status2`,不新增 billing 支付接口。
+- 不接入个人 Storage 探测、预签名上传和完成确认 API;媒体仅写入本地。
+- 增加当前账户鉴权和数据隔离测试。
+- 三个 VOC 网关返回用量响应头。
+
+P1:
+
+- 大模型调用接入统一 `usageTraceId`。
+- 增加企业/Agent 展示维度。
+- 支付成功事件主动唤醒任务运行时,减少 Agent 轮询。
+
+### 13.2 `claude-code-voc-intelligence`
+
+P0:
+
+- 新增 `voc-cost-controller` skill。
+- 新增计费客户端、任务上下文和预算守卫。
+- 新增充值客户端和本地媒体流水线;默认二维码、自助链接作为第二入口。
+- 注册 `voc_cost_estimate` 与 `voc_usage_report` 两个 MCP 工具。
+- `voc_api_call` 传追踪头并读取实际用量头。
+- 小红书/抖音专用 collector 使用同一网关和用量上下文。
+- 所有会发起计费采集的现有 Skill 引用同一工作流计划、价目查询、追踪、预算守卫和 VOC 402 处理规则。
+- 所有采集结果递归识别多媒体并写入本地媒体清单。
+- 报告展示 URL 固定替换为本地缓存路径,不上传线上 Storage。
+- 建立通用成本规划协议,并以小红书和天猫两个场景作为首批确定性模板。
+- 报告输出增加成本摘要。
+
+P1:
+
+- 基于历史实际值校准预估区间。
+- 自动生成成本优化建议。
+- 后续新增 Skill 强制接入通用协议,新平台只提供步骤规划适配器。
+
+### 13.3 `fmode-studio`
+
+P0:
+
+- 保留现有充值和订阅逻辑。
+- 新增总览、用量、计费规则页签。
+- 接入统一 billing query 接口。
+- 支持执行追踪/报告下钻和 CSV 导出。
+- 余额不足提示显示缺口与建议充值金额。
+- 验证并保持现有 Token 免登录、二维码、支付按钮和到账刷新能力。
+- Agent 模型调用 402 时由 Studio/宿主运行时直接显示现有充值入口,不依赖模型继续执行。
+
+P1:
+
+- 展示 Agent 三档报价记录与最终实付偏差。
+- 预算阈值设置和通知。
+
+### 13.4 `voc-profit-web`
+
+- 复用新的统一聚合服务。
+- 增加价目一致性、未定价和未归集告警。
+- 增加任务、工作流和企业维度。
+- 保留上游成本、利润等管理员专属字段。
+
+## 14. 实施顺序
+
+### 阶段 A:先把账记准
+
+1. 建路径级价目表。
+2. 统一规则解析。
+3. 日志写实际金额和价目版本。
+4. 三个 VOC 模块接入任务追踪头。
+5. 完成扣费一致性和幂等测试。
+
+没有完成阶段 A 前,不应上线对外“精确任务成本”。
+
+### 阶段 B:让 AI 会算
+
+1. 上线统一 billing query 接口,按筛选返回余额、价目和实际用量。
+2. 技能包新增成本 MCP 工具,由 Agent 自己生成调用计划和报价。
+3. 通用规划协议接入,并完成两个首批验收模板。
+4. 超阈值任务强制预估,批次间检查预算。
+5. 报告附实际用量摘要。
+6. VOC 余额不足时 Agent 默认返回现有支付二维码,同时提供 Balance 链接;模型余额不足由宿主运行时兜底。
+
+### 阶段 B2:让媒体可持续访问
+
+1. 统一递归识别 API 返回中的图片、视频和音频。
+2. 生成本地目录和 `media-manifest.json`。
+3. 统一媒体发现和本地缓存,暂不接入个人 Storage 上传。
+4. 报告生成前统一替换为 `effectiveUrl`。
+5. 增加媒体完整性检查和断点续传。
+
+### 阶段 C:让用户看懂
+
+1. Balance 页面接入统一 billing query 接口。
+2. Studio 增加总览、用量、规则视图。
+3. 执行追踪/报告/接口下钻。
+4. 充值缺口和二维码闭环。
+
+### 阶段 D:经营优化
+
+1. 经营看板接入任务维度。
+2. 对比用户收入和上游成本。
+3. 基于实际数据校准工作流估算系数。
+4. 输出单位有效样本成本和高成本低价值告警。
+
+## 15. 测试与验收
+
+### 15.1 后端单元测试
+
+- 同一路径在不同价目版本下解析正确。
+- 路径规则优先于模块规则。
+- 缓存命中按配置扣费。
+- 日志 `quota`、`actual_cost_cny` 和余额变化一致。
+- 5xx 失败不扣费,重试成功只扣一次。
+- 同一幂等键参数变化返回冲突。
+- 普通账户只能查询自己的用量。
+- 共享账户可按 token/client 标签展示,但权限仍以账户为边界。
+- billing query 只能返回当前 Token 所属用户的余额和用量。
+- Agent 传入的 `usageTraceId` 不能突破当前用户边界。
+- 现有支付订单重复回调不会重复增加 NewAPI 余额。
+- 媒体处理不读取 Storage AK/SK,也不调用线上 Storage 接口。
+
+### 15.2 技能 smoke
+
+新增覆盖:
+
+- 无 token 查询公开价目时的产品行为。
+- 有效 token 的余额和估算。
+- 余额不足返回缺口和充值入口,`errors=[]`。
+- 余额不足时能拼接只携带 Token 的现有 Balance 链接,用户在页面内自行选择金额。
+- 默认复用 `recharge/order + pay_code2` 取得二维码,并同时生成带 Token 的 Balance 自助链接。
+- 模拟支付成功后重新查询余额并从原检查点继续。
+- 280 账号和 134 商品两类计划公式正确。
+- 预算达到 80% 时 warning。
+- 达到硬上限时保存部分结果并停止新增调用。
+- 除用户明确需要的充值直达链接外,普通输出、日志和报告不含 token、Authorization 或请求凭证。
+- `voc_api_call` 的 `usage` 与模拟响应头一致。
+- 图片、视频和音频均先写入本地缓存和媒体清单。
+- 所有情况下报告均引用本地文件;失败时不继续使用上游热链。
+
+建议脚本:
+
+```text
+scripts/smoke-billing-query.js
+scripts/smoke-cost-estimate.js
+scripts/smoke-budget-guard.js
+scripts/smoke-trend-budget.js
+scripts/smoke-usage-report.js
+scripts/smoke-recharge-client.js
+scripts/smoke-media-pipeline.js
+```
+
+并纳入:
+
+```powershell
+npm run mcp:smoke
+npm run smoke:package
+```
+
+### 15.3 前端验收
+
+- 余额、今日、7 日、本月金额与 API 返回一致。
+- 模块合计等于总消费。
+- 同一执行追踪下各接口合计等于该次执行总消费。
+- 历史推算记录明确标记,不能伪装成精确值。
+- 移动端表格可横向查看或切换为分组列表。
+- Agent 返回的付费链接打开后不出现登录页,直接进入充值视图。
+- Agent 可以直接返回可扫码支付的二维码。
+- 模型调用返回余额不足时,无需模型继续生成回复,Studio/宿主运行时仍能显示充值入口。
+- 充值后 Balance 余额自动刷新;当前 Agent 会话查询新余额后从原检查点继续。
+- CSV 不包含敏感字段。
+
+### 15.4 通用能力与首批业务样例验收
+
+所有会发起计费采集的 VOC 技能先通过同一组通用验收:能够生成工作流调用计划、查询同一权威价目、携带 `usageTraceId`、执行预算保护、处理 VOC 402,并输出可按模块和接口核对的实际账单。下面两个业务场景只作为首批规模化回放样例。
+
+小红书场景:
+
+- 输入 280 个账号、179 个直链、105 个待匹配账号。
+- 能输出三档计划和逐接口预估。
+- 模拟在第 147 个账号余额不足,账单能精确显示已覆盖和剩余成本。
+- 充值后使用同一 `usageTraceId` 和本地检查点续跑,不重复扣已完成批次。
+- 账号头像、笔记图片和视频均进入媒体清单,报告使用本地缓存。
+
+天猫场景:
+
+- 输入 5 页、134 个 SKU、8 个代表 SKU。
+- 经济版不对 134 个商品做全详情/全评论。
+- 标准版能显示全店轻量采集和代表商品深采的成本拆分。
+- 失败版本探测显示尝试次数,但实际消费只按后端扣费日志计算。
+- 商品主图、详情长图、视频和买家秀全部本地缓存,报告引用本地 URL。
+
+## 16. 监控与告警
+
+上线后至少监控:
+
+- `unpriced_endpoint_count > 0`。
+- `abs(log_cost - quota_converted_cost) > tolerance`。
+- `trace_actual / trace_estimated_high > 1`。
+- 单次执行失败率、重试率异常。
+- 同一执行追踪重复 `requestId` 冲突。
+- 用量查询跨账户访问被拒绝。
+- 余额页聚合总额与 NewAPI 用户 `used_quota` 增量不一致。
+
+## 17. 兼容与迁移
+
+- 现有 `APIGNewApiBillingMap` 保留,作为模块级默认价。
+- 现有 `logs` 不迁表;新增字段写入 `other`。
+- 历史日志按旧公式展示并标记 `mixed`。
+- 现有 VOC 网关 JSON 响应保持兼容,通过响应头先交付用量。
+- `r:` Session Token 继续用于用户身份、Balance 免登录和支付授权,但新模式下的数据调用必须映射到该用户的 NewAPI 账户并扣减 NewAPI 通用余额;不能再把 `APIGAuth.count` 作为正常回退计费路径。
+- 现有余额、充值、订阅入口保持可用,新页面渐进增加用量能力。
+- 现有带 Token 的 Balance 免登录链接、二维码、支付轮询和余额刷新继续复用;Agent 计算建议金额后,默认用于现有二维码建单链路,Balance 链接仍由用户在页面内自行选金额。
+- 已发布报告中的旧远程媒体链接不批量迁移;新任务和续跑任务强制进入媒体流水线。
+- 现有管理员经营看板继续使用独立权限,不下放全局数据接口。
+
+## 18. 完成定义
+
+本需求完成必须同时满足:
+
+1. AI 能通过接口读取当前有效计费规则。
+2. 所有已接入计费采集的 VOC 工作流在执行前都能给出可解释的三档预估,首批两个复杂任务通过规模化验收。
+3. 每次实际调用能通过 `usageTraceId` 关联到报告、技能、步骤和接口。
+4. 余额页能按模块、执行追踪、报告和接口展示调用量与消费。
+5. 大模型与 VOC 数据费用分列,无法精确归集的部分明确标记。
+6. 预算上限能阻止任务继续超支,并保留部分成果。
+7. 用户能看到高成本项、优化建议、预计节省和信息损失。
+8. 充值完成后任务可从检查点继续,不重复已完成调用。
+9. Agent 能直接返回免登录充值链接或支付二维码,用户只需完成支付。
+10. 所有采集多媒体都有本地缓存;本轮不上传线上 Storage。
+11. 除现有充值直达链接所需 Token 外,账户密钥和会话凭证不写入代码、日志、报告或普通工具输出。
+
+### 18.1 当前验收状态(2026-09-02)
+
+本节是对第 18 节完成定义的代码复核结果,状态以当前工作区和已执行测试为准:
+
+| 完成定义 | 状态 | 复核结论 |
+| --- | --- | --- |
+| AI 读取当前有效计费规则 | 已完成 | 后端统一 `POST /api/fmode/billing/query` 返回余额、价目和版本;`voc_billing_query`/`voc_cost_estimate` 已注册。 |
+| 所有已接入工作流执行前三档预估 | 部分完成 | 通用规划器覆盖小红书账号监听、天猫 Listing 和自定义 operations;其他新工作流需提供规划适配器。 |
+| 每次调用关联 usageTraceId/report/skill/step/endpoint | 已完成(已接入入口) | `voc_api_call`、小红书/抖音 live 网关请求发送 `X-Fmode-*` Header,后端写入 `logs.other`;历史日志标记 mixed/reconstructed。 |
+| Balance 按模块、追踪、报告、接口展示 | 已完成(前端已接入) | Studio Balance 用量页调用统一接口,展示调用、quota、实际消费、模块/接口明细和计费规则。 |
+| 大模型与 VOC 费用分列 | 部分完成 | 用户用量账与平台成本报告已拆分;模型侧只有具备同一 trace 的日志才能精确合并。 |
+| 预算上限阻止超支并保留部分成果 | 已完成(已接入批处理入口) | `batch-budget-guard` 在小红书/抖音 live 每个关键词批次前读取当前余额与追踪消费,支持 80% 预警、硬预算/余额阻断、差额和建议充值;首批前阻断也会写入 `checkpoint.json`,`resume` 会跳过已完成关键词。经营闭环复用同一趋势入口;跨会话自动唤醒仍由宿主运行时负责。 |
+| 高成本低价值项优化建议 | 已完成(首批任务) | Agent 估算报告输出 valueScore、可删项和节省额;管理员看板 `/usage-diagnostics` 已按任务和接口展示成本排行、缓存命中和预计节省。 |
+| 充值后从检查点继续且不重复调用 | 已完成(工具级续跑) | VOC 402 分支保留部分结果并写检查点;支付后以同一 `usageTraceId` + `resume` 续跑,已完成业务键和分页不会重复调用。跨会话自动唤醒仍由宿主运行时负责。 |
+| Agent 返回免登录链接或支付二维码 | 已完成 | VOC 402 由统一 recharge client 复用现有 `pay_code2/order_status2`,二维码失败保留 Token Balance URL;Studio/宿主已对模型 402 直接打开现有充值入口。 |
+| 采集多媒体并固定本地缓存 | 已完成(小红书/抖音 live) | 通用 discovery/manifest/pipeline 已接入;`effectiveUrl` 固定为本地路径,线上 Storage 上传已关闭。其他采集入口需继续接入 pipeline。 |
+| 凭证不写入代码、日志、报告 | 已完成(静态与 smoke 范围) | 请求凭证只作运行时输入,输出经过脱敏;发布包扫描和安装 smoke 通过。 |
+
+当前可以验收的 P0 交付是:统一报价、真实扣费归集、充值入口、Balance 用量页、管理员任务级成本诊断、两类首批工作流、live 媒体持久化和本地续跑检查点。后续工作集中在“为尚未接入的历史批处理 Skill 增加规划适配器、跨会话自动唤醒、更多采集入口接入媒体流水线、基于真实账号的端到端回放和历史预估校准”,不影响本轮已交付闭环。
+
+## 19. 建议首个迭代范围
+
+首个可上线迭代建议只做以下闭环:
+
+1. `APIGEndpointPricing` 和统一规则解析。
+2. `usageTraceId/reportId/stepId` 写入 VOC 扣费日志。
+3. 当前账户统一 `/api/fmode/billing/query` 接口。
+4. 技能包 `voc_cost_estimate` 与 `voc_usage_report` 两个 MCP 工具。
+5. Agent 拼接现有带 Token 的 Balance 链接或返回现有二维码,到账后查询余额并续跑。
+6. 多媒体本地缓存和媒体清单;本轮关闭线上 Storage 上传。
+7. 通用工作流成本规划协议,以及小红书 280 账号、天猫 134 商品两个首批验收模板。
+8. Studio 余额页增加模块拆分、执行记录和接口 Top 消耗。
+
+这个范围已经能回答本次业务提出的三个核心问题:预计多少钱、实际花在哪里、下一次怎么少花但保留关键结果。
+
+## 20. 代码落地复核与原逻辑影响分析
+
+本节是在对四个仓库的实际代码入口再次核对后形成的落地结论,用于开发排期、任务拆分和代码评审。前文定义产品行为与接口契约,本节明确代码具体落在哪里、如何分阶段接入,以及哪些已有行为必须保持兼容。
+
+### 20.1 复核结论
+
+整体方案可以落地,但实现时必须遵守以下边界:
+
+1. 所有任务无论规模大小,都按真实 API/模型调用实时扣除用户 NewAPI 通用余额,不建立任务余额、预付款或一次性任务支付。
+2. `usageTraceId` 只是 Agent 本地生成并写入日志的执行追踪标签,用于把不同报告的调用频次和实际消费分开;不需要服务端任务表和任务 CRUD。
+3. 报价由 Agent 完成。服务端只通过统一 query 接口提供当前余额、筛选后的有效价目和实际用量,不根据业务类型生成调用计划或最终报价。
+4. 充值中心已有 Token 免登录、余额展示、支付二维码/按钮、支付轮询和到账刷新。本需求复用现有能力:Agent 默认按建议金额组装现有支付请求并返回二维码,另一入口是带 Token 的 Balance 自助链接。
+5. “所有采集多媒体必须缓存”会改变当前 live 工具行为。旧参数继续保留,但 `cacheAssets=false` 不再允许跳过 live 数据的持久化,`assetLimit` 只控制报告展示数量,不再控制实际缓存数量。
+6. MCP 使用 stdio 通信,只能在当前 Agent 会话存活时轮询并续跑。真正跨会话自动唤醒需要已有 Agent runtime/callback 或新增后台任务队列,不属于单独修改 VOC 技能包就能完成的能力。
+7. `future-server` 生产环境通过根目录 `server.js` 加载 `.modules/*.min.cjs`。改完模块源码后必须重新构建并更新对应运行时产物,否则现网不会获得新逻辑。
+8. 管理员利润看板和用户 Balance 页是两个权限面。前者可以查看全局经营数据,后者只能查询当前身份对应的用量;不能直接复用管理员接口给普通用户。
+9. `r:` Session Token 已通过 `hasNewApiBillingIdentity()` 映射到同一 NewAPI 计费身份;三个 VOC 网关在新模式下使用统一 NewAPI 扣费,`APIGAuth.count` 仅保留给明确的旧兼容路径。技能端链接、实际扣费和 Balance 用量因此使用同一账户口径。
+
+### 20.2 仓库与文件修改总表
+
+#### 20.2.1 后端 `E:\workspace\server\future-server`
+
+| 类型 | 文件位置 | 修改职责 | 风险 |
+| --- | --- | --- | --- |
+| 修改 | `fmode-server/modules/shared/newapi-metering.ts` | 将 `resolveQuota()` 扩展为统一规则解析;真实扣费、日志金额和响应计费信息使用同一解析结果;写入任务追踪字段 | 高 |
+| 修改 | `fmode-server/modules/shared/newapi-metering.ts` | 集中完成 `sk-`/`r:` 身份解析、Session-to-NewAPI 映射、路径级价目匹配、额度检查、幂等扣费、实际金额和追踪日志写入 | 高 |
+| 修改 | `fmode-server/modules/voc-social-api/src/routes.ts` | 接收白名单任务上下文、按路径解析价格、写日志、返回 usage 响应头 | 高 |
+| 修改 | `fmode-server/modules/voc-e-commerce/src/routes.ts` | 与社媒网关保持相同计费和追踪行为 | 高 |
+| 修改 | `fmode-server/modules/voc-ecom-api/src/routes.ts` | 与社媒网关保持相同计费和追踪行为 | 高 |
+| 修改 | `fmode-server/api/routes.ts` | 在现有主路由中提供统一 `POST /api/fmode/billing/query`;媒体 Storage 路由不由本技能包调用 | 高 |
+| 修改 | 三个模块的构建脚本或 `scripts/build.mjs` | 确保共享计费代码进入产物,并复制到根目录 `.modules` | 中 |
+| 生成 | `.modules/voc-social-api.min.cjs`、`.modules/voc-e-commerce.min.cjs`、`.modules/voc-ecom-api.min.cjs` | 三个 VOC 网关的生产实际加载产物;由各模块构建生成,不手工编辑。billing 路由随 `fmode-server/api/routes.ts` 加载,不单独生成 CJS | 高 |
+| 保持并复用 | `api/api-ncloud/fmode/routes.js` 的 `/recharge/order` | Agent 和 Balance 页创建或复用充值订单及 `AccountLog` | 中 |
+| 修改并复用 | `api/api-ncloud/fmode/sync-fmode-api.js`、`newapi-user-resolution.js` | 抽取可被计费模块复用的 Session 用户到 NewAPI 用户映射;保持 `syncUser()` 和 Token 创建幂等 | 高 |
+| 保持并回归 | `api/api-ncloud/fmode/serv-user.js`、`routes.js`、`cloud/user/auth-route.js` | 继续承担现有钱包、NewAPI 划拨、`pay_code2` 二维码和 `order_status2` 状态,不引入任务支付绑定 | 高 |
+| 修改 | `api/api-ncloud/profit/service/revenue.service.js` | 管理员聚合优先读取新的实际金额、任务和路径字段,保留历史兼容公式 | 中 |
+| 修改 | `api/api-ncloud/profit/routes.js` | 增加管理员执行追踪/报告/接口聚合,不改变现有鉴权 | 中 |
+| 新增 | 后端迁移脚本 | 创建 `APIGEndpointPricing` 和 NewAPI 日志查询所需索引,不创建任务支付表 | 高 |
+| 新增 | 后端单元及集成测试 | 覆盖用户隔离、查询筛选、价格一致性、扣费幂等和历史日志兼容 | 高 |
+
+注意:`AuthService.guardAuthToken()` 负责入口鉴权,统一计费身份和价目解析集中在 `shared/newapi-metering.ts`。billing query 强制使用当前请求解析出的 `userId`,筛选条件只能作为附加条件,不能跨用户查询。
+
+#### 20.2.2 VOC 技能包 `E:\workspace\openclaw-voc-skill\claude-code\claude-code-voc-intelligence`
+
+| 类型 | 文件位置 | 修改职责 | 风险 |
+| --- | --- | --- | --- |
+| 修改 | `mcp/src/core/credentials.js` | 将字符串式 Token 读取改为类型化凭证上下文,分别返回 `newApiToken`、`sessionToken`、来源和可用能力,禁止把任意 `vocToken` 当成可拼 Balance 链接的 Session | 高 |
+| 修改 | `mcp/src/core/payment-links.js` | 增加 Tokenized Balance URL 构造和统一充值结果;旧 APIG/workshop helper 仅保留给旧产品流程 | 高 |
+| 新增 | `mcp/src/core/billing-client.js` | 封装统一 billing query 请求;按场景选择 balance/pricing/usage,统一隐藏凭证和底层错误 | 中 |
+| 修改 | `mcp/src/providers/voc-gateway.js` | 接收类型化双凭证、发送追踪头、返回实际认证/计费模式并读取 usage 响应头;统一 401/402/403 | 高 |
+| 修改 | `mcp/src/providers/ecommerce-gateway.js` | 与 social provider 保持同一认证、计费、错误和响应上下文 | 中 |
+| 修改 | `mcp/src/providers/overseas-gateway.js` | 与 social provider 保持同一认证、计费、错误和响应上下文 | 中 |
+| 修改 | `mcp/src/providers/xiaohongshu-api.js`、`douyin-api.js` | 移除独立请求/错误分类,改为复用统一 social gateway;保留平台便捷方法 | 高 |
+| 修改 | `mcp/src/features/xiaohongshu-trend/live-collector.js`、`douyin-trend/live-collector.js` | 传递完整凭证和 usage context,保留结构化错误类型、requestId 与部分进度 | 高 |
+| 新增 | `mcp/src/core/usage-context.js` | 本地创建和传递 `usageTraceId/reportId/skillId/workflowType/stepId/clientId` | 中 |
+| 新增 | `mcp/src/features/voc-cost/workflow-planner.js`、`estimate-report.js` | 生成经济版、标准版、完整版调用计划、成本区间和用户可读报价 | 中 |
+| 新增 | `mcp/src/core/budget-guard.js` | 批次执行前检查余额和预算,输出差额、充值建议及检查点 | 中 |
+| 新增 | `mcp/src/core/recharge-client.js` | 计算差额后默认调用现有建单/支付码链路返回二维码,同时提供带 Token 的 Balance 自助链接,并在到账后查询余额 | 中 |
+| 新增 | `mcp/src/features/media/media-pipeline.js` | 发现图片/视频/音频,下载、去重、校验、写 manifest 并固定使用本地 `effectiveUrl` | 中 |
+| 新增 | `mcp/src/features/media/media-discovery.js` | 从不同接口的嵌套响应中按字段映射和 MIME 类型发现媒体,避免用单一字段名硬编码 | 中 |
+| 修改 | `mcp/src/features/xiaohongshu-trend/assets.js` | 迁移到通用媒体流水线;保留旧导出作为兼容适配器 | 中 |
+| 修改 | `mcp/src/tools/voc-api-catalog-run.js` | 支持任务上下文、usage、默认输出目录和通用媒体副作用 | 中 |
+| 修改 | `mcp/src/tools/xiaohongshu-trend-run.js` | 接入预估、预算、检查点、全量媒体缓存和充值续跑 | 中 |
+| 修改 | `mcp/src/tools/douyin-trend-run.js` | 与小红书工作流保持同一规则 | 中 |
+| 修改 | `mcp/src/features/voc-business-workflow/business-workflow.js` | 汇总大模型费用与 VOC 数据费用,输出成本价值建议 | 中 |
+| 修改 | `mcp/src/features/fmode-image-analysis/image-analysis.js` | 复用统一错误分类与 NewAPI 充值结果;严格区分模型 402 和权限 403 | 高 |
+| 修改 | `mcp/src/core/result-envelope.js` | 给所有工具统一增加 `usage/estimate/recharge/checkpoint` 可选字段,保持原字段兼容 | 中 |
+| 修改 | `mcp/src/core/api-catalog.js`、`mcp/catalog/voc-social-endpoints.json`、`mcp/catalog/params/*.json` | 使用稳定 endpoint 标识和计费单位元数据;删除把 `billing: 1` 当人民币价格的行为,价格始终来自 billing query | 高 |
+| 新增 | `mcp/src/tools/voc-cost-estimate-run.js` | MCP `voc_cost_estimate` 的实现 | 低 |
+| 新增 | `mcp/src/tools/voc-usage-report-run.js` | MCP `voc_usage_report` 的实现 | 低 |
+| 修改 | `mcp/src/server.js` | 注册新工具和 schema;调整 `cacheAssets/assetLimit` 描述及兼容语义 | 中 |
+| 修改 | `mcp/src/core/activation.js` | 缺 Token 时只做配置恢复,不再打开旧 APIG 充值页;只在确认 402 且存在 Session 时生成个人充值入口 | 中 |
+| 修改 | `skills/voc-api-catalog/SKILL.md` | 加入执行前估算、批次预算检查、余额不足返回支付入口、媒体强制持久化规则 | 低 |
+| 修改 | `skills/xiaohongshu-trend-intelligence/SKILL.md`、`skills/douyin-trend-intelligence/SKILL.md` | 补充成本和媒体行为,不要求用户理解内部参数 | 低 |
+| 修改 | 其他所有会发起计费采集的 `skills/*/SKILL.md` 或统一入口 Skill | 引用同一成本规划、追踪和 VOC 余额不足规则,避免能力只覆盖两个验收样例 | 中 |
+| 修改 | `skill-package-manifest.json`、`package.json` | 登记新工具、脚本和能力说明 | 低 |
+| 修改 | `scripts/smoke-mcp.js`、`scripts/smoke-package.js` | 覆盖工具发现、结构化输出、敏感信息、充值和媒体兼容 | 中 |
+| 新增 | `scripts/smoke-cost-estimate.js`、`smoke-recharge-client.js`、`smoke-media-pipeline.js` | 独立验证 Agent 报价、现有充值入口衔接和媒体流水线 | 中 |
+
+`voc_api_call` 是通用入口,不能假设所有接口都有统一媒体字段。首期由 endpoint catalog 增加可选 `mediaFields` 映射;未登记接口再使用受限的 URL/MIME 发现器,并把无法确认的候选项写入 warning,避免把普通链接误当作文件下载。
+
+#### 20.2.3 Studio `E:\workspace\fmode-studio`
+
+| 类型 | 文件位置 | 修改职责 | 风险 |
+| --- | --- | --- | --- |
+| 新增 | `projects/fmode-studio/src/components/billing-center.service.ts` | 封装统一 billing query,供余额、规则和用量视图使用 | 中 |
+| 新增 | `projects/fmode-studio/src/components/billing-usage.component.ts` | 展示模块、执行追踪、报告和接口用量 | 中 |
+| 保持并回归 | `projects/fmode-studio/src/app/app.routes.ts`、`auth-guard.service.ts` | 复用已确认的 `/balance` 路由和 `token/session` 免登录识别;保持消费 Token 后清理地址栏 | 中 |
+| 修改 | `projects/fmode-studio/src/modules/launcher/fmode-home.component.ts` | 保持现有 Balance 打开方式并接入用量视图 | 中 |
+| 修改 | `projects/fmode-studio/src/components/balance-modal.component.ts`、`balance-page.component.ts` | 保留现有余额、二维码、支付轮询和到账刷新,只接入用量子组件 | 中 |
+| 修改 | `projects/fmode-studio/src/modules/codex-workspace/pages/codex-shell/*` | 保持 Codex 工作区打开余额页的现有行为,并接入新用量视图 | 中 |
+| 修改 | `projects/fmode-studio/src/modules/codex-workspace/pages/codex-shell/codex-shell.component.ts` 及模型请求错误适配层 | 拦截模型调用 402,在不依赖 Agent 回复的情况下直接打开现有 Balance 充值入口 | 高 |
+| 新增 | 对应 `*.spec.ts` | 回归 Token 免登录、自助选金额、二维码、支付轮询、到账刷新和用量展示 | 中 |
+
+Studio 仓库当前这些相关文件已有未提交修改。实际编码必须以当前工作树为基线做增量编辑,不能覆盖或回退既有改动。
+
+#### 20.2.4 管理员看板 `E:\workspace\voc-profit-web`
+
+| 类型 | 文件位置 | 修改职责 | 风险 |
+| --- | --- | --- | --- |
+| 修改 | `client/src/app/api.service.ts` | 扩展管理员任务、报告、接口、价格版本和数据质量类型及请求 | 低 |
+| 修改 | `client/src/app/dashboard.component.ts` | 增加任务成本排行、接口 Top 消耗、估算偏差和低价值高成本诊断 | 中 |
+| 修改 | `client/src/app/dashboard.component.html` | 在现有“模块转发明细”上补任务/报告视角,不复制 Balance 用户页 | 中 |
+| 修改 | `client/src/app/dashboard.component.scss` | 支持新增表格和筛选状态 | 低 |
+| 可选修改 | `client/src/app/app.routes.ts` | 若单独增加任务成本页面则新增路由;首期可复用现有 modules 页面 | 低 |
+
+`voc-profit-web/server/src/index.ts` 只是静态站点和 `/api/profit` 路由装载器,业务聚合仍在 `future-server/api/api-ncloud/profit`。首期没有新增服务器逻辑时无需修改该文件。
+
+### 20.3 后端具体实施方案
+
+#### 20.3.1 统一计费规则和真实扣费
+
+一次 VOC 网关请求按以下顺序处理:
+
+1. 严格认证并只从认证结果取得 `userId/tokenId`。
+2. 规范化 `model + method + proxyPath`,移除 query 和动态值。
+3. `resolveBillingRule()` 读取当前生效的路径规则;找不到时按现有模块映射回退,并标记 `matchedBy=fallback`。
+4. `assertNewApiQuotaAvailable()` 和 `chargeNewApiUsage()` 接收同一个 `ResolvedBillingRule` 对象,避免检查额度和实际扣费使用不同价格。
+5. 上游或缓存请求成功后仅扣一次;重试使用同一 `requestId/idempotencyKey`。
+6. 将实际 `quota`、`actual_cost_cny`、价格版本和任务字段写入 `logs.other`。
+7. 返回兼容的原 JSON,同时增加非敏感 usage 响应头。旧客户端不读取响应头也不受影响。
+
+不能让网关先按 `pathQuota` 扣费、日志再按 `APIGNewApiBillingMap.price_cny` 记另一金额。规则解析结果必须是额度检查、扣除、日志和展示四处共享的唯一对象。
+
+#### 20.3.2 当前账户用量查询
+
+`POST /api/fmode/billing/query` 不调用管理员 profit API,而是直接基于严格解析出的当前 `userId` 查询 NewAPI `logs`:
+
+- 查询条件强制包含 `user_id = currentUserId`。
+- `usageTraceId/reportId` 只能作为附加条件,不能替代用户条件。
+- `groupBy` 使用服务器白名单映射到固定 SQL 表达式,不能把客户端字段直接拼进 SQL。
+- 新日志读取 `actual_cost_cny`;旧日志回退 `price_cny` 或 quota 换算,并返回 `quality=mixed/reconstructed`。
+- 对 `from/to`、分页大小和最大时间范围设置上限,避免 Balance 页形成大范围全表扫描。
+- 建议索引至少覆盖 `logs(user_id, created_at)`,并评估 `other` 中 `usage_trace_id/report_id` 的表达式索引或结构化列。
+
+#### 20.3.3 Agent 与现有充值中心衔接
+
+不新增任务支付会话,按以下方式衔接现有充值中心:
+
+1. Agent 调用 billing query 取得当前余额和价目,根据本地剩余计划自行计算差额和建议充值金额。
+2. 默认按建议金额调用现有 `/api/fmode/recharge/order` 创建或复用订单,再调用 `pay_code2` 取得支付码并返回二维码。
+3. 另一入口拼接 `balance/?token=USER_TOKEN`;用户打开后在现有页面自行选择金额。
+4. `BalanceModalComponent.startPayment()`、`pay_code2`、`order_status2`、钱包账本和 `topUpNewApiQuota()` 继续按现有链路运行。
+5. 充值到账后,Balance 页面继续使用现有逻辑刷新余额。
+6. 当前 Agent 会话再次调用 billing query;余额足够后从 `usageTraceId` 对应的本地检查点继续。
+
+充值只增加 NewAPI 通用余额,不记录为某个 `usageTraceId` 的任务收入。现有支付链路仍需保持 `tradeNo` 幂等,但这属于原充值能力的回归要求,不是新增任务支付模型。
+
+#### 20.3.4 本地媒体缓存
+
+媒体处理只保留本地事实层:
+
+- 下载成功即形成可交付的本地事实,计算 `sha256` 并写入目标文件。
+- 相同 `sha256` 在同一任务内只保留一份实体文件,manifest 可以有多个业务引用。
+- `effectiveUrl` 固定为本地相对路径,HTML 报告和标准化 JSON 均不引用线上地址。
+- `cloudUrl` 固定为 `null`,`uploadStatus` 固定为 `disabled_local_only`。
+- 不调用 `storage/profile`、预签名上传或 complete 接口,避免产生线上 Storage 流量。
+- 下载失败记录原因并清空展示地址;报告不回退到上游热链。
+
+### 20.4 Studio 具体实施细节
+
+#### 20.4.1 URL 与凭证处理
+
+复用需求方确认的现有 Balance 路由:
+
+```text
+https://app.fmode.cn/dev/studio/balance/?token=USER_TOKEN
+```
+
+加载顺序:
+
+1. 页面读取 `token` 并复用现有免登录识别逻辑取得当前用户。
+2. 用户在现有金额选项中自行选择充值金额。
+3. 继续使用现有余额查询、二维码、支付按钮、订单轮询和到账刷新。
+4. Token 不出现在日志、分析事件、页面正文或错误信息中;现有页面识别完成后已从可见地址栏移除。
+
+远端 `origin/master` 已确认 `auth-guard.service.ts` 同时接受 `token` 和 `session`,调用 `Parse.User.become(token)` 后清理地址栏;`app.routes.ts` 已存在 `/balance` 路由。当前本地分支落后且有重叠的未提交改动,实际编码时以现有工作树为基线合并这些远端行为,不重建支付页面,也不覆盖本地修改。
+
+#### 20.4.2 普通充值与 Agent 充值并存
+
+- 普通登录用户仍可从 `BalanceModalComponent` 手动选金额并调用原 `startPayment()`。
+- Agent 自助链接进入同一个 Balance 页面,通过 Token 免登录后由用户选择金额。
+- 两种入口使用完全相同的支付供应商、账务入账和 NewAPI 划拨服务。
+- `balance-modal.component.ts` 已承担余额、支付、订阅和轮询等职责;新增用量查询和展示应下沉到 service 和子组件,不改写现有支付主体。
+
+### 20.5 技能包具体实施细节
+
+#### 20.5.1 参数兼容迁移
+
+| 现有参数 | 当前语义 | 新语义 | 兼容行为 |
+| --- | --- | --- | --- |
+| `cacheAssets` | 是否缓存少量首图 | 是否在报告中展示缓存媒体的兼容开关 | sample 模式可按旧值;live 模式始终持久化,传 `false` 时返回弃用 warning |
+| `assetLimit` | 最多缓存多少张首图,最大 30 | 报告最多展示多少项媒体 | 不限制实际缓存;schema 可保留原字段名并新增 `mediaDisplayLimit` |
+| `output` | trend 工具输出目录 | 报告和媒体根目录 | 保持兼容 |
+| 无输出目录的 `voc_api_call` | 只返回 JSON | 默认创建任务输出目录和 manifest | 可通过结构化 `files` 告知目录,不改变原始 `data.result` |
+
+默认目录建议为工作区下 `.voc/runs/<usageTraceId>/` 或现有 output 根目录下的 `<usageTraceId>/`。路径选择必须由统一 helper 完成,不能由各工具分别拼接。
+
+#### 20.5.2 检查点和防重复调用
+
+每个批次写入检查点:
+
+```json
+{
+  "usageTraceId": "USAGE_TRACE_ID",
+  "stepId": "collect_account_notes",
+  "completedItemKeys": ["ITEM_KEY"],
+  "nextCursor": "CURSOR",
+  "requestIds": ["REQUEST_ID"],
+  "mediaManifestPath": "assets/media-manifest.json",
+  "updatedAt": "ISO_TIME"
+}
+```
+
+续跑前先读取检查点和后端任务实付记录:
+
+- 已完成的业务键不再请求。
+- 状态不确定但已有成功 `requestId` 的调用不再扣费重放。
+- 上游返回失败且后端没有成功扣费记录时才进入重试队列。
+- 支付只解除预算或余额阻断,不重置任务和分页游标。
+
+#### 20.5.3 自动续跑能力分级
+
+| 等级 | 行为 | 所需改动 | 首期 |
+| --- | --- | --- | --- |
+| L0 | 支付完成后用户回到 Agent 触发继续 | 现有余额刷新 + 本地检查点 | 保底能力 |
+| L1 | 当前 Agent 会话周期性查询 billing query,余额到账后续跑 | 技能 `recharge-client` + 当前进程存活 | P0 目标 |
+| L2 | Agent 会话关闭后,支付回调唤醒后台任务并跨会话续跑 | Agent runtime/callback、持久任务队列、回调签名和执行租约 | 后续独立迭代 |
+
+产品文案中的“充值完成后自动继续任务”在 P0 指 L1。若要求用户关闭聊天或进程后仍自动生成报告,则验收目标必须升级为 L2,并明确增加运行时仓库或服务端任务执行器的改造范围。
+
+### 20.6 原逻辑影响矩阵
+
+| 原有能力 | 影响方式 | 兼容策略 | 必测回归 |
+| --- | --- | --- | --- |
+| VOC 网关 JSON 响应 | 增加日志字段和响应头,不改主 JSON | 旧客户端忽略新响应头 | 三通道正常、缓存、上游失败、重试 |
+| NewAPI 扣费 | 路径级规则替代部分模块级默认价 | 无路径规则时回退现有映射;灰度比对后启用扣费 | 额度检查与实际扣除一致、重复请求幂等 |
+| NewAPI 历史日志 | 不迁移、不回写 | 查询时标记 `mixed/reconstructed` | 新旧日志合并金额和质量标识 |
+| 普通余额查询 | 增加模块/执行追踪/接口视图 | 现有余额和订阅接口继续可用 | 余额总额、充值记录、订阅显示 |
+| 现有充值中心 | 不重做;默认 Agent 直出二维码,Token 链接供用户自助选金额 | 保留原 Balance、二维码、轮询和到账刷新 | 免登录、自助选金额、建单、扫码、到账 |
+| 支付回调划拨 | 不改变业务模型 | 继续复用 `topUpNewApiQuota()` 和原账本 | 重复回调、超时回调、部分失败补偿 |
+| trend 图片缓存 | 从少量首图变为 live 全媒体 | 旧导出保留适配;展示上限与缓存上限分离 | 图片、视频、音频、失败降级 |
+| `voc_api_call` | 增加任务目录和媒体副作用 | 原 `data.result/raw` 结构保持 | 已登记和 rawPath 接口、无媒体接口 |
+| 报告媒体地址 | 新报告改用本地 `effectiveUrl` | 旧报告不批量迁移 | 本地浏览、断网展示 |
+| 管理员利润看板 | 增加任务/接口维度 | 保持管理员鉴权,不复用到用户侧 | 全局数据完整、历史公式兼容 |
+
+风险排序:
+
+- 高风险:身份隔离、真实扣费、价格一致性和用户用量越权。
+- 中风险:Balance 聚合、任务检查点、媒体下载、报告 URL 替换、模块构建部署。
+- 低风险:新增展示字段、CSV 导出、技能说明和能力清单。
+
+### 20.7 明确不调整的原有逻辑
+
+首期明确不做以下改动:
+
+1. 不替换现有支付供应商,也不取消普通登录用户的充值入口。
+2. 不创建一套独立的 VOC 钱包,不绕开现有钱包和 NewAPI quota 划拨。
+3. 不把管理员 `/api/profit` 权限或全局用户数据下放给 Balance 页面。
+4. 不批量迁移或重写历史 NewAPI 日志;历史账单只做查询时兼容解释。
+5. 不批量抓取和迁移已经发布的旧报告媒体;只约束新任务和续跑任务。
+6. 不把 Storage AK/SK、支付商户密钥或用户 Token 写入技能文件、报告、日志和分析事件。
+7. 不改变三个 VOC 网关的上游接口选择和原始业务响应结构。
+8. 不把 `clientId/skillId` 当作强身份依据;真正的账户边界始终来自服务端认证结果。
+
+### 20.8 建议提交拆分与上线门禁
+
+为降低高风险逻辑互相干扰,建议按以下提交和发布单元推进:
+
+1. **计费只观测**:路径规则解析、任务字段和 usage 响应头先写影子金额,不改变扣费;对比现有 quota。
+2. **计费生效**:灰度启用路径级扣费,开启差异告警和一键回退到模块级价格。
+3. **用户账单**:上线严格用户隔离的统一 billing query,先供内部账户验证。
+4. **技能估算**:发布 `voc_cost_estimate`、预算检查和通用工作流计划协议,以小红书和天猫模板完成首批验收,不接支付。
+5. **充值衔接**:验证现有 Token 直达、自助选金额、Agent 按建议金额生成二维码和到账刷新;只补 Agent 调起方式,不重做支付。
+6. **当前会话续跑**:接入检查点和 L1 自动续跑,验证中断后不重复采集。
+7. **媒体流水线**:先用小红书/抖音工具完成灰度验证,再在同一 P0 发布门禁前覆盖通用 `voc_api_call` 和其他计费采集入口。
+8. **看板**:Studio 面向用户展示;`voc-profit-web` 增加管理员成本优化分析。
+
+每个发布单元必须具备独立开关或回退点:
+
+- 路径级计费可回退模块级价格。
+- Agent 直达链接异常时仍可从现有 Balance 入口充值。
+- 媒体处理始终保留本地缓存交付,不产生线上上传流量。
+- 新用量聚合异常时不影响实际调用和扣费。
+- L1 续跑异常时任务保持 `partial`,可从检查点人工继续。
+
+### 20.9 最终交付物
+
+完成实施后应交付以下可验收成果,而不只是代码提交:
+
+1. 四个仓库的分仓代码变更和变更说明。
+2. 数据结构迁移脚本、路径级初始价目表和价格版本说明。
+3. 统一 billing query 和现有充值调用链文档及可执行示例;媒体仅提供本地缓存说明。
+4. VOC 技能包新版本、安装包和 MCP 工具清单。
+5. 通用 VOC 工作流的执行前估算、执行中预算状态和执行后账单样例,并附小红书 280 账号与天猫 134 商品两类首批验收结果。
+6. 免登录支付链接与支付二维码的桌面端、移动端验收录像或截图。
+7. 支付成功后当前会话自动续跑、重复回调不重复到账的测试证据。
+8. 图片、视频、音频的本地缓存样例及对应 `media-manifest.json`。
+9. Studio 用户用量页和 `voc-profit-web` 管理员成本优化视图。
+10. 自动化测试结果、灰度发布步骤、监控项和回滚手册。
+
+## 21. 现有技能包计费链路审计与完整修改清单
+
+本节以当前代码实际执行路径为依据,补全“改哪些位置才能让新模式真正跑通”。结论是:计费和充值逻辑目前分散在通用接口目录、两个平台趋势工具、业务闭环、图片分析、Token 检查和激活流程中。只修改 `voc_api_call` 或只替换一处充值 URL,无法覆盖实际报告任务。
+
+### 21.1 当前端到端链路
+
+#### 21.1.1 通用 `voc_api_call`
+
+当前过程:
+
+1. `credentials.js` 分别尝试读取 NewAPI `sk-` Token 和 VOC Token,但 VOC Token 返回值没有类型保证。
+2. `voc-api-catalog-run.js` 优先把 `sk-` 传给 social/ecommerce/overseas gateway,同时把另一个 Token 作为鉴权失败回退。
+3. 三个 provider 只有在第一次请求被分类为 `auth` 时才使用回退 Token;402 和 403 不回退。
+4. 后端收到 `sk-` 时通过 `newapi-metering.ts` 扣减 NewAPI 用户和 Token quota,并写 NewAPI `logs`。
+5. 后端收到 `r:` 时走旧 `APIGAuth.count` 查询和扣减,不进入同一 NewAPI 日志。
+6. NewAPI 402 回到工具后,当前仍生成固定 Studio 旧入口;Session 分支和部分错误分支仍生成旧 APIG/workshop 链接。
+
+这条链路已经能区分大部分 401、402、403、参数和上游错误,但还不能保证“同一用户统一 NewAPI 余额、统一账单、统一充值入口”。
+
+#### 21.1.2 小红书和抖音趋势报告
+
+两个 trend runner 当前先执行:
+
+```text
+readNewApiToken(input) || readVocToken(input)
+```
+
+这会把两种凭证压缩成一个字符串。后续 `live-collector.js` 分别实例化 `XiaohongshuApi` 或 `DouyinApi`,它们没有携带另一种凭证,也没有复用通用 gateway 的回退结果和 usage 响应。因此存在以下后果:
+
+- `sk-` 鉴权失败后不能可靠用 Session 身份继续。
+- 专用 API 类和通用 gateway 的错误分类可能不同。
+- 402、403 和包含“权限/余额”的历史返回会被 trend runner 二次猜测。
+- 全失败和部分成功后的充值分支多次调用 `buildVocRechargeInfo()`,继续返回旧 APIG/workshop 入口。
+- 部分报告只保存采集数据,没有统一 `usageTraceId`、实付金额、检查点和可续跑状态。
+
+#### 21.1.3 业务闭环和图片分析
+
+- `voc-business-workflow` 调用 trend runner;非 `ok` 时会包一层业务提示。它会保留底层文本,但没有稳定透传 `recharge/usage/estimate/checkpoint`,以后很容易在包装时丢链接或二维码。
+- `fmode-image-analysis` 直接调用模型网关,并把 HTTP 402 和 403 都当成 `needs_recharge`;同时使用旧 `buildVocRechargeInfo()`。这会把权限问题错误引导到充值,并且无法和 VOC 数据费用使用同一结果协议。
+- Agent 自身的大模型请求在余额耗尽时,模型可能已经不能生成下一条回复。这个场景必须由 Studio/Agent runtime 拦截 402 并打开现有 Balance 页面,不能只依赖 Skill 返回链接。
+
+#### 21.1.4 Token 检查和激活
+
+- `mcp/src/server.js` 中的小红书/抖音 Token check 目前主要判断“是否读到字符串”,不验证 Token 类型、有效性、NewAPI 余额和 Session 是否能生成个人充值入口。
+- 两个平台的 Token 选择顺序不完全一致。
+- `activation.js` 在缺 Token 时会构造旧 workshop/APIG 页面。缺 Token 属于配置恢复,不能等同于余额不足。
+- 只有经过类型校验的 `r:` Session Token 才能放进 `balance/?token=...`;`sk-`、空值或任意 `vocToken` 都不能用于拼个人链接。
+
+#### 21.1.5 当前价目和额度口径
+
+当前目录和后端规则的实际情况如下:
+
+| 位置 | 当前事实 | 新模式处理 |
+| --- | --- | --- |
+| `mcp/catalog/voc-social-endpoints.json` | 共 1,344 个接口:social 1,018、ecommerce 289、overseas 37 | 目录只保存稳定 endpoint 和计费单位元数据,不作为价格权威源 |
+| social 目录 | 1,018 个接口没有 endpoint 级 `billing` | 必须通过 billing query 按 endpoint 筛选读取服务端有效价目 |
+| ecommerce/overseas 目录 | 326 个接口多数写有 `billing: 1` | 只表示一次调用单位,不能解释为 ¥1 或真实 quota |
+| `newapi-metering.ts` | quota 优先级为 `model:path pathQuota -> pathQuota -> options.quota -> modelQuota -> APIGNewApiBillingMap.quota -> 默认值` | 统一收敛为 `resolveBillingRule()`,检查、扣费、日志和展示共享结果 |
+| NewAPI quota 检查 | 同时检查用户总 quota 和 Token remain quota;任一不足返回 402 | 保持双重额度约束,并在 billing query 返回可解释的可用余额 |
+| 人民币换算 | `quota / 500000 * USDExchangeRate`;充值反向换算 | 保持现有 NewAPI 换算权威,价格版本记录汇率和换算基准 |
+| 缓存 | 当前三个 VOC 模块均可能对缓存命中计费 | 将政策写入 endpoint 规则并在报价、实扣和账单中明确展示 |
+
+因此,Agent 的成本公式只能是“计划调用单位 × billing query 返回的当前单价”,不能是“目录 `billing` 字段 × 调用数”。实际消费以 NewAPI 成功扣费日志为准。
+
+### 21.2 必须修复的计费冲突
+
+#### 21.2.1 Session 回退必须统一扣 NewAPI
+
+目标语义不是“`sk-` 扣 NewAPI,`r:` 扣旧 VOC 次数”,而是:
+
+| 请求凭证 | 身份解析 | 数据调用扣费 | 充值用途 |
+| --- | --- | --- | --- |
+| 有效 `sk-` | NewAPI `tokens -> users` | 当前 NewAPI 用户通用余额 | 若同时有 Session,可返回个人 Balance 链接和二维码 |
+| 有效 `r:` | Parse `_Session -> _User -> NewAPI users.parse_id` | 映射后的同一 NewAPI 用户通用余额 | 可生成 Tokenized Balance 链接并授权现有支付链 |
+| `sk-` 401 且 `r:` 有效 | 改用 Session 身份解析 | 仍扣映射后的 NewAPI 用户通用余额 | 同上 |
+| 无有效凭证 | 不建立计费身份 | 不发起数据调用 | 返回配置/登录恢复状态,不伪造个人链接 |
+
+后端需要把计费函数从“只接受请求头里的 `sk-`”改成“接受严格解析后的 `NewApiBillingPrincipal`”。建议统一 principal:
+
+```ts
+type NewApiBillingPrincipal = {
+  authMode: "newapi_token" | "session_newapi";
+  userId: number;
+  tokenId: number;
+  parseUserId?: string;
+  group: string;
+};
+```
+
+`assertNewApiQuotaAvailable()`、`chargeNewApiUsage()`、日志写入和 billing query 都使用该 principal。Session 分支可以复用 `syncUser()` 已有的 `users.parse_id` 映射和 Token 幂等创建能力,但不能把解析出的 NewAPI Token 返回给普通数据响应或写入日志。
+
+切换新模式后,三个 VOC 网关的正常请求路径不再调用:
+
+- `getApiAuth()`;
+- `deductApiAuthCount()`;
+- `APIGAuth.count` 的余额检查。
+
+这些函数可以在迁移期受开关保护以便回滚,但不能和 NewAPI 同时扣费。上线前必须用同一 Session 用户验证:NewAPI quota 减少、NewAPI 日志新增、旧 `APIGAuth.count` 不变。
+
+#### 21.2.2 单价、检查、实扣和账单必须共用一个规则
+
+当前 `newapi-metering.ts` 的 quota 解析依次考虑路径配置、显式 quota、模型配置、模块映射和默认值,但日志中的 `price_cny` 可能仍来自模块级 `APIGNewApiBillingMap`。新模式应在请求开始时只解析一次:
+
+```ts
+type ResolvedBillingRule = {
+  endpointId: string;
+  model: string;
+  method: string;
+  normalizedPath: string;
+  unit: "call" | "item" | "page" | "token";
+  quota: number;
+  unitPriceCny: number;
+  pricingVersion: string;
+  cachePolicy: "charged" | "free" | "discounted";
+  matchedBy: "endpoint" | "module" | "fallback";
+};
+```
+
+额度预检、真实扣减、`logs.other.actual_cost_cny`、响应 usage 和 Balance 展示必须使用同一个对象。`mcp/catalog/voc-social-endpoints.json` 里的 `billing: 1` 只能表示历史调用单位,不能再被 Agent 当作人民币价格。
+
+#### 21.2.3 业务成功后才扣一次
+
+三个 VOC 网关需要统一以下顺序:
+
+1. 认证并解析 principal。
+2. 解析价格并预检额度。
+3. 查询缓存或调用上游。
+4. 通过统一 `isBillableBusinessSuccess()` 判断业务响应成功。
+5. 使用 `requestId + principal.userId + endpointId` 幂等扣费。
+6. 写实际日志并返回 usage。
+
+缓存是否计费由 `ResolvedBillingRule.cachePolicy` 决定,不能由三个路由各自硬编码。上游 HTTP 成功但业务 code 失败、参数错误、权限错误和最终失败的重试不能产生成功扣费日志。
+
+### 21.3 类型化凭证和统一状态机
+
+`mcp/src/core/credentials.js` 应返回凭证上下文,而不是让每个工具自行 `A || B`:
+
+```js
+{
+  newApiToken: "<secret-or-empty>",
+  sessionToken: "<secret-or-empty>",
+  sources: {
+    newApiToken: "request|env|claude-settings|fmode-config|none",
+    sessionToken: "request|env|credential-file|none"
+  },
+  capabilities: {
+    canCallWithNewApi: true,
+    canCallWithSessionIdentity: true,
+    canBuildPersonalBalanceUrl: true,
+    canCreatePaymentOrder: true
+  }
+}
+```
+
+所有入口使用同一状态机:
+
+| 条件 | 工具状态 | Agent 动作 |
+| --- | --- | --- |
+| 没有任何可用调用凭证 | `needs_token` | 返回配置或登录恢复步骤;不称为余额不足,不生成个人链接 |
+| 凭证存在但认证失败 | `needs_valid_token` | 返回凭证恢复步骤;不生成充值二维码 |
+| 402 且有有效 Session | `needs_recharge` | 返回差额、建议金额、Tokenized Balance 链接;Agent 传 `createPaymentQr=true` 时复用现有支付链生成二维码 |
+| 402 但没有 Session | `needs_session` | 保留检查点并返回登录/Session 恢复入口;不拼假链接 |
+| 403 | `needs_permission` | 提示账号或接口权限处理,不引导充值 |
+| 参数错误 | `needs_input` | 返回缺失字段和参数文档,不引导充值 |
+| 上游失败 | `upstream_unstable` | 按规则重试或保留部分结果,不引导充值 |
+| 余额足够 | `ok` 或继续执行 | 带追踪上下文执行并记录实付 |
+
+其中 `needs_session` 是充值交互所需身份缺失,不代表数据账户没有余额。若产品不希望增加对外状态,也可以对外仍用 `needs_token`,但结构化 `summary.errorKind` 必须是 `missing_session_for_recharge`,避免 Agent 把它解释成 402。
+
+### 21.4 Agent 链接和二维码输出协议
+
+所有计费工具的充值输出统一进入 `recharge`,业务工作流只能透传或补充说明,不能重新拼 URL:
+
+```json
+{
+  "status": "needs_recharge",
+  "assistantMessage": "当前余额不足,还差 ¥12.40,建议充值 ¥20.00。可直接扫码支付,或打开个人充值页面。",
+  "summary": {
+    "shortageCny": 12.4,
+    "suggestedRechargeCny": 20,
+    "completedItems": 147,
+    "remainingItems": 133
+  },
+  "recharge": {
+    "paymentMode": "qr_and_balance_url",
+    "balanceUrl": "https://app.fmode.cn/dev/studio/balance/?token=USER_TOKEN",
+    "qrCodeUrl": "QR_CODE_URL",
+    "qrImagePath": "OUTPUT_QR_IMAGE_PATH",
+    "tradeNo": "TRADE_NO",
+    "polling": {
+      "supported": true,
+      "status": "pending"
+    }
+  },
+  "checkpoint": {
+    "usageTraceId": "USAGE_TRACE_ID",
+    "path": "CHECKPOINT_PATH",
+    "canResume": true
+  },
+  "files": ["OUTPUT_QR_IMAGE_PATH", "CHECKPOINT_PATH"],
+  "nextActions": ["完成支付后继续当前任务"],
+  "warnings": [],
+  "errors": []
+}
+```
+
+规则:
+
+1. `shortageCny = max(estimatedRemainingHighCny - currentBalanceCny, 0)`。
+2. `suggestedRechargeCny` 由 Agent 根据差额、平台允许金额档位和小幅缓冲计算,传给现有建单链路;它不是服务端报价,也不绑定任务。
+3. `balanceUrl` 不携带金额。用户进入现有 Balance 页后自行选择充值金额。
+4. 二维码路径复用 `/api/fmode/recharge/order -> pay_code2 -> order_status2`;`voc_billing_query` 传 `createPaymentQr=true` 时由技能包调用现有支付码链路并返回 `qrCodeUrl`。这三个是现有接口,不计入新增接口数。
+5. 只新增 `POST /api/fmode/billing/query`。报价、差额和建议金额均由 Agent 基于该接口返回的数据计算。
+6. 在工具输出和持久化物中,Session Token 只允许出现在最终明确交付给当前用户的 `balanceUrl`;运行时仍可放入 HTTPS 请求的认证头或现有支付请求。日志、诊断、usage、报告正文、二维码文件名、`tradeNo`、埋点和错误信息必须脱敏。
+7. 如果二维码创建失败但 Session 有效,仍返回 Tokenized Balance 链接和检查点,`paymentMode=balance_url`。
+8. 支付到账后重新调用 billing query;余额达到继续条件时从检查点续跑,不重置分页、不重复调用已完成项。
+
+### 21.5 技能包逐文件修改清单
+
+#### 21.5.1 核心层
+
+| 文件 | 必须修改的行为 |
+| --- | --- |
+| `mcp/src/core/credentials.js` | 增加 `readCredentialContext()` 和 Token 类型校验;保留旧 reader 作为兼容包装;明确 Session 来源,禁止把 `sk-` 作为 Balance Token |
+| `mcp/src/core/payment-links.js` | 新增 `buildTokenizedBalanceUrl(sessionToken)`;新增统一 recharge formatter;旧 APIG/workshop URL 只供明确的旧产品流程,不再用于 NewAPI 402 |
+| `mcp/src/core/billing-client.js` | 调用唯一 billing query;支持 `include`、endpoint 筛选、追踪筛选;返回结构化 balance/pricing/usage |
+| `mcp/src/core/usage-context.js` | 创建并传递 `usageTraceId/reportId/skillId/workflowType/stepId/requestId`;写本地运行元数据 |
+| `mcp/src/core/budget-guard.js` | 接收 Agent 计划和价格,计算三档预估、剩余成本、差额、建议充值金额;批次前阻断超预算 |
+| `mcp/src/core/recharge-client.js` | 使用 Session 调现有建单、支付码、状态查询;写二维码文件;返回 Tokenized URL;到账后刷新余额并触发 L1 续跑 |
+| `mcp/src/core/result-envelope.js` | 在保持现有字段的前提下统一可选 `usage/estimate/recharge/checkpoint`;确保失败友好状态 `errors=[]` |
+| `mcp/src/core/activation.js` | 缺 Token 只返回配置恢复;确认 402 才进入统一 recharge;移除默认打开旧 APIG/workshop 页的行为 |
+
+#### 21.5.2 Provider 和采集层
+
+| 文件 | 必须修改的行为 |
+| --- | --- |
+| `mcp/src/providers/voc-gateway.js` | 接收 credential context;携带追踪头;记录实际 `authMode/billingMode`;响应返回 usage;401 时才尝试 Session 身份;402/403 不混淆 |
+| `mcp/src/providers/ecommerce-gateway.js` | 复用同一个 `callGateway()` 或公共基类,只保留根地址与 envelope 差异 |
+| `mcp/src/providers/overseas-gateway.js` | 复用统一调用器,同时保留 `/forward` 请求封装 |
+| `mcp/src/providers/xiaohongshu-api.js` | 删除独立 fetch/error classifier,改用 social gateway;每个便捷方法传 endpoint、credential context 和 usage context |
+| `mcp/src/providers/douyin-api.js` | 与小红书相同,保证两个平台专用路径不再绕过统一计费和充值逻辑 |
+| `mcp/src/features/xiaohongshu-trend/live-collector.js` | 不再只收一个 `token`;传递 credential/usage context;每个搜索、详情、评论调用保留 requestId、费用和业务键 |
+| `mcp/src/features/douyin-trend/live-collector.js` | 同上;视频、评论和翻页均写检查点并汇总实际 usage |
+
+#### 21.5.3 Tool 和业务编排层
+
+| 文件 | 必须修改的行为 |
+| --- | --- |
+| `mcp/src/tools/voc-api-catalog-run.js` | 删除 `endpoint.billing || 1` 作为报价依据;查询服务端价格;返回实际 auth/billing mode;统一生成充值结果和媒体流水线输出 |
+| `mcp/src/tools/xiaohongshu-trend-run.js` | 删除所有分散的 `buildVocRechargeInfo()` 分支;使用统一预算、检查点和 recharge;部分结果也返回同一链接/二维码协议 |
+| `mcp/src/tools/douyin-trend-run.js` | 与小红书完全同源,不再重复猜测 402/403 |
+| `mcp/src/features/voc-business-workflow/business-workflow.js` | 非 `ok` 时原样透传 `recharge/usage/estimate/checkpoint/files`;成功时合并各步骤实际费用,不只包装文字 |
+| `mcp/src/tools/voc-business-workflow-run.js` | schema 和输出契约增加预算、估算、账单与续跑字段 |
+| `mcp/src/features/fmode-image-analysis/image-analysis.js` | 使用类型化 credential;402 使用统一 NewAPI recharge,403 返回 permission;模型 usage 写同一追踪上下文 |
+| `mcp/src/tools/fmode-image-analysis.js` | schema 注册 usage context 和统一结果字段,不泄露 Token |
+| `mcp/src/tools/voc-cost-estimate-run.js` | 接收 Agent 计划,筛选所需 endpoint 价目并输出三档报价;不让服务端决定调用次数 |
+| `mcp/src/tools/voc-usage-report-run.js` | 按当前用户和追踪标识读取实际频次、quota、人民币费用和质量标识 |
+| `mcp/src/server.js` | 注册新工具;统一 Token check;所有计费工具 schema 支持预算和上下文,但继续允许自然语言调用 |
+
+#### 21.5.4 目录、Skill 文档和发布物
+
+| 文件 | 必须修改的行为 |
+| --- | --- |
+| `mcp/src/core/api-catalog.js` | 对外输出稳定 `endpointId/channel/method/path/billingUnit/mediaFields`;不输出伪价格 |
+| `mcp/catalog/voc-social-endpoints.json` | 修正三通道认证和计费说明;为已登记接口补稳定 endpoint 元数据;去除“Session 回退扣旧额度”表述 |
+| `mcp/catalog/params/*.json` | 仅保留参数和计费单位相关元数据,权威价格不复制到技能包 |
+| `skills/voc-api-catalog/SKILL.md`、`skills/voc-api-catalog/references/error-codes.md` | 作为全包计费规则的单一说明源;统一 401/402/403、Session-to-NewAPI、Tokenized Balance 和 QR 规则 |
+| `skills/xiaohongshu-trend-intelligence/*`、`skills/douyin-trend-intelligence/*` | 删除旧固定 Studio/APIG 链接和旧 Session 额度描述;引用统一规则;要求部分报告保留充值及检查点 |
+| `skills/voc-business-workflow/SKILL.md` | 要求成本预估、实际账单和充值上下文穿透业务包装 |
+| `skills/fmode-image-analysis/SKILL.md` | 统一模型 402/403 和充值输出 |
+| `skills/voc-issue-pool/SKILL.md`、`voc-problem-deep-dive/SKILL.md`、`voc-competitor-map/SKILL.md`、`voc-content-plan/SKILL.md`、`voc-speaking-script/SKILL.md` | 说明自身直接费用或继承上游/Agent 模型费用的规则;组成业务闭环时共享同一 usage trace,避免账单漏项 |
+| `README.md`、`docs/payment-package-links.md`、`docs/live-manual-acceptance-checklist.md` | 更新用户流程、接口数量边界、二维码和免登录链接示例;示例只用占位 Token |
+| `.claude-plugin/plugin.json`、`skill-package-manifest.json`、`package.json`、`package-lock.json` | 同步工具注册、版本和产物清单 |
+
+### 21.6 后端逐文件修改清单
+
+| 文件 | 必须修改的行为 |
+| --- | --- |
+| `fmode-server/modules/shared/newapi-metering.ts` | 接受统一 principal 和 resolved rule;同一事务检查用户/Token quota、幂等扣费、写 actual cost 和追踪字段 |
+| `fmode-server/modules/shared/newapi-user-auth.ts` | 严格校验 `sk-`,解析真实 NewAPI 用户;补齐当前 `AuthService` 对 `sk-` 未验证的问题 |
+| `fmode-server/modules/shared/session-newapi-principal.ts` | 严格校验 Session;按 Parse user id 查 NewAPI `users.parse_id`;必要时调用幂等同步;返回计费 principal |
+| `fmode-server/modules/shared/voc-pricing.ts` | 统一 endpoint 路径规范化、价格读取、版本、缓存政策和模块回退 |
+| `fmode-server/modules/voc-social-api/src/routes.ts` | 移除新模式正常路径上的 `APIGAuth.count`;业务成功才调用统一 metering;接收追踪字段;返回 usage |
+| `fmode-server/modules/voc-e-commerce/src/routes.ts` | 与 social 路由共享 principal、价格、成功判断和扣费顺序 |
+| `fmode-server/modules/voc-ecom-api/src/routes.ts` | 与前两者一致;保留海外 `/forward` 业务封装 |
+| `api/api-ncloud/fmode/sync-fmode-api.js` | 保持 `syncUser()` 幂等,提供 Session 用户到 NewAPI 账户映射能力;不得在普通日志输出 Token |
+| `api/api-ncloud/fmode/newapi-user-resolution.js` | 继续处理 `parse_id/username` 冲突,增加计费身份映射测试 |
+| `fmode-server/api/routes.ts` + `fmode-server/modules/shared/newapi-metering.ts` | 实现唯一新增用户计费查询接口;同一认证解析 balance、pricing、usage;强制当前 user 条件和筛选白名单。Storage 路由不由技能包调用 |
+| `api/api-ncloud/fmode/routes.js` | 保持 `/recharge/order` 契约;回归 `tradeNo` 幂等、金额校验和 Session 身份 |
+| `api/api-ncloud/fmode/serv-user.js`、`sync-fmode-api.js`、`cloud/user/auth-route.js` | 回归 `AccountLog -> pay_code2 -> order_status2 -> topUpNewApiQuota`;重复回调不重复到账 |
+| `api/api-ncloud/profit/*` | 管理员看板读取新实际金额和追踪字段,同时兼容历史日志 |
+| `fmode-server/api/routes.ts`、三个模块构建脚本、`.modules/voc-*.min.cjs` | 源码路由和生产构建同时包含共享认证/计费实现;无需单独的 billing `.min.cjs` |
+
+### 21.7 Studio 和 Agent runtime 修改边界
+
+Studio 已有 `/balance`、Token 免登录、NewAPI 余额、支付二维码、订单轮询和到账刷新,首期不重做这些能力。需要的调整只有:
+
+1. Balance 页通过 billing query 增加模块、执行追踪、报告和接口用量视图。
+2. 保持 `token/session -> Parse.User.become()` 和消费后清理地址栏。
+3. 模型请求层拦截 Agent 自身的 402;如果本地会话身份可用,直接打开 Tokenized Balance 页或现有 Balance 弹窗。
+4. Agent 尚能回复、且是 VOC 技能中途 402 时,由 Skill 的 recharge client 返回二维码和链接。
+5. Studio 不接管 Agent 的调用计划和报价计算,也不新增任务支付模型。
+
+由于 `fmode-studio` 当前本地分支存在未提交且与远端重叠的修改,编码时必须以工作树为基线增量合并,禁止用远端文件整体覆盖。
+
+### 21.8 完整回归矩阵
+
+#### 21.8.1 身份和扣费
+
+- 有效 `sk-` 调用 social、ecommerce、overseas:只扣对应 NewAPI 账户一次。
+- 有效 Session 调用三通道:映射到同一 NewAPI 账户并扣一次,`APIGAuth.count` 不变。
+- `sk-` 401 + 有效 Session:Session 身份继续请求,仍写 NewAPI 日志。
+- 用户 NewAPI quota 不足和 Token remain quota 不足都返回 402,且不调用上游。
+- 403 用户禁用或接口权限不足返回 `needs_permission`,不生成充值入口。
+- 缓存命中、上游成功、业务失败、超时重试分别验证扣费次数和 cache policy。
+- 相同 `requestId` 重放不重复扣费;相同 id 搭配不同 endpoint/quota 返回冲突。
+
+#### 21.8.2 Agent 输出和充值
+
+- 402 + Session:`assistantMessage` 同时含差额、建议金额、可点击 Tokenized Balance 链接,并优先返回二维码文件或 URL。
+- Tokenized URL 只在显式 `recharge.balanceUrl` 和当前用户消息出现;其他字段、日志、报告、测试快照均不出现 Session。
+- 402 + 无 Session:返回 Session/登录恢复状态和检查点,不生成错误的个人 URL。
+- 缺 Token、401、403、参数错误、上游失败都不误报成余额不足。
+- `/recharge/order -> pay_code2 -> order_status2` 能建单、出码、轮询和到账;重复建单/回调保持幂等。
+- 支付完成后 billing query 返回新余额;当前会话从检查点续跑,已完成业务键不重采。
+- 二维码失败时仍可用 Tokenized Balance 链接;链接打开失败时仍可从已登录 Studio Balance 入口支付。
+
+#### 21.8.3 技能和组合工作流
+
+- `voc_api_call` 三通道均返回同一 usage/recharge 结构。
+- 小红书搜索、详情、评论任一步 402 时,完整失败和部分成功都保留正确链接、二维码、实付和检查点。
+- 抖音对应路径执行同样用例。
+- `voc_business_workflow` 不丢失底层 `recharge/usage/estimate/checkpoint/files`。
+- 图片分析 402 返回 NewAPI 充值结果,403 返回 permission。
+- Token check 能区分 `sk-`、Session、错误类型和是否具备个人支付能力,而不回显凭证。
+- 激活流程缺 Token 时不再跳旧 APIG/workshop 充值页。
+- 所有收费 Skill 执行前通过 billing query 读取当前价目;任何 Skill 都不从 catalog `billing` 推导人民币价格。
+
+#### 21.8.4 文档、产物和安全检查
+
+- `smoke-mcp` 覆盖新工具发现、统一结果字段和业务工作流透传。
+- `smoke-package` 检查所有计费 Skill 引用统一规则且无旧链接。
+- 新增 credential、gateway、billing client、recharge client、Session principal、metering、二维码和续跑测试。
+- 构建 zip、npm 包和 `.modules` 后再次扫描明文凭证、旧固定充值入口、`amount=` Balance 链接和把 `usageTraceId` 当支付 ID 的表述。
+- 生产验收记录同一用户执行前后 NewAPI 余额、NewAPI 日志、旧 `APIGAuth.count`、支付到账和报告实际账单,四者必须一致。
+
+### 21.9 上线依赖和门禁
+
+新技能包不能先于 Session-to-NewAPI 后端计费能力全量发布,否则 `sk-` 失败后的 Session 回退仍会扣旧余额。发布顺序应为:
+
+1. 后端增加 unified principal、影子价格和 usage 日志,暂不改变扣费。
+2. 验证 `sk-` 与 Session 映射到同一用户,并对比影子金额。
+3. 灰度切换三通道到 NewAPI 统一扣费,确认旧 `APIGAuth.count` 不变。
+4. 上线 billing query。
+5. 发布技能包类型化凭证、统一 gateway、Agent 报价和 recharge client。
+6. 回归 Studio Tokenized Balance、二维码、支付到账和模型层 402 拦截。
+7. 最后启用通用预算保护、L1 续跑、媒体流水线和用量看板。
+
+必须提供以下开关:
+
+- Session-to-NewAPI 计费灰度开关;回滚时只能切回一种计费路径,禁止双扣。
+- endpoint 价格规则开关;异常时回退模块级 NewAPI 规则。
+- Agent 二维码开关;异常时保留 Tokenized Balance URL。
+- L1 自动续跑开关;异常时保留检查点供用户继续。
+
+### 21.10 本次审计后的最终判断
+
+要保证新模式正常运行,P0 不能只做页面和一个查询接口。必须同时完成以下最小闭环:
+
+1. 后端 `sk-` 和 Session 两种身份都统一扣 NewAPI 余额。
+2. 技能包所有真实采集入口都经过同一 credential、gateway、error 和 recharge context。
+3. Agent 只调用一个新增 billing query 读取余额、价目和用量,并自行计算报价、差额和建议金额。
+4. Agent 复用现有三段支付链返回二维码,同时返回带 Session 的 `/balance/?token=...` 自助链接。
+5. 组合工作流和部分报告完整透传充值、账单和检查点,支付后不重复已完成调用。
+6. Studio 继续承担现有充值体验,并在 Agent 自身无法回复的模型 402 场景兜底打开充值入口。
+
+这六项同时完成,才能满足“所有任务统一扣 NewAPI、Agent 能估价、余额不足能直接付费、充值后能继续、Balance 能看清用量”的完整业务目标。

+ 1 - 0
claude-code/claude-code-voc-intelligence/install.js

@@ -113,6 +113,7 @@ function checkFiles() {
     'skills/voc-business-workflow/SKILL.md',
     'skills/fmode-image-analysis/SKILL.md',
     'skills/voc-api-catalog/SKILL.md',
+    'skills/voc-cost-controller/SKILL.md',
     'mcp/src/tools/fmode-image-analysis.js',
     'mcp/src/tools/voc-api-catalog-run.js',
     'mcp/catalog/voc-social-endpoints.json',

+ 9 - 0
claude-code/claude-code-voc-intelligence/mcp/catalog/params/taobao.json

@@ -53,6 +53,15 @@
       "desc": "淘宝/天猫上的唯一商品标识符(商品ID)。"
     }
   ],
+  "taobao.get_item_detail_v7": [
+    {
+      "name": "itemId",
+      "in": "query",
+      "type": "string",
+      "required": true,
+      "desc": "淘宝/天猫上的唯一商品标识符(商品ID)。"
+    }
+  ],
   "taobao.get_item_detail_v9": [
     {
       "name": "itemId",

+ 46 - 0
claude-code/claude-code-voc-intelligence/mcp/catalog/voc-social-endpoints.json

@@ -22617,6 +22617,7 @@
         "displayed_sales",
         "buyer_count"
       ],
+      "maxRecommendedAgeHours": 48,
       "freshness": "recordTime 是上游数据采集时间;返回前必须与当前时间比较,不把请求时间当作采集时间。",
       "responseHint": "页面销量读取 data.sellCount;该版本不返回商品价格。",
       "tags": [
@@ -22656,6 +22657,7 @@
         "coupon_price",
         "discount_price"
       ],
+      "maxRecommendedAgeHours": 48,
       "freshness": "recordTime 是上游数据采集时间;优惠后价仍可因账号、SKU、地区、优惠券和活动时间与结算页不同。",
       "responseHint": "价格单位通常为分:itemPrice 是标价,DiscountPrice 是优惠后价,SKU 读取 sku2info.finalPrice/finalSkuPrice。",
       "tags": [
@@ -22727,6 +22729,7 @@
         "sku_price",
         "promotion_price"
       ],
+      "maxRecommendedAgeHours": 48,
       "freshness": "recordTime 是上游数据采集时间;返回前必须与当前时间比较,不把请求时间当作采集时间。",
       "responseHint": "价格优先读取 itemPrice.price / promotionPrice 及 SKU 价格;页面展示销量以 v3 的 sellCount 为准。",
       "tags": [
@@ -22749,6 +22752,49 @@
       ],
       "billing": 1
     },
+    {
+      "id": "taobao.get_item_detail_v7",
+      "platform": "taobao",
+      "channel": "ecommerce",
+      "title": "淘宝天猫·新鲜基础详情(V7)",
+      "summary": "实时发起采集,获取淘宝或天猫商品的当前标题、基础价格、SKU、库存和店铺信息。需要 48 小时内的基础详情时优先使用;销售量级、完整属性和优惠后成交口径不是该版本的稳定字段。",
+      "method": "GET",
+      "paramsIn": "query",
+      "proxyPath": "taobao/get-item-detail/v7",
+      "params": [],
+      "requiredParams": [
+        "itemId"
+      ],
+      "recommendationPriority": 110,
+      "recommendedFor": [
+        "fresh_product_detail",
+        "current_title",
+        "current_base_price",
+        "current_sku"
+      ],
+      "maxRecommendedAgeHours": 48,
+      "freshness": "V7 会实时发起采集;仍须检查 recordTime,超过 48 小时则只作历史参考。业务码 301 表示采集失败,应自动重试;连续失败后明确返回本次不可用。",
+      "responseHint": "标题读取 subject,基础价读取 price,SKU 读取 skuVoList,店铺读取 shopName。monthSold/baseAttrList 可能为空,不能用 V7 单独承诺页面销量、完整属性或券后价。",
+      "tags": [
+        "taobao",
+        "淘宝天猫",
+        "淘宝",
+        "天猫",
+        "tmall",
+        "Taobao and Tmall",
+        "ecommerce",
+        "电商",
+        "商品",
+        "product",
+        "商品详情",
+        "实时采集",
+        "当前价格",
+        "SKU",
+        "fresh product detail",
+        "current price"
+      ],
+      "billing": 1
+    },
     {
       "id": "taobao.get_item_detail_v9",
       "platform": "taobao",

+ 7 - 34
claude-code/claude-code-voc-intelligence/mcp/src/core/activation.js

@@ -6,15 +6,14 @@
 // - 复用 credentials.js 的 readNewApiToken()(sk- 主链)/ readVocToken()(r: 会话回退)。
 // - 按「是否已有可用 token」这一状态触发,而不是按命令触发:
 //     * 已检测到 token(飞马 UI 已写入,或 Claude Code 已配 sk-)→ 打印「✅ 已激活」,不弹浏览器。
-//     * 完全没有 token(现场新教练)→ 安装收尾立刻打开充值/登录页,避免进入取数流程后才弹、空耗 token
+//     * 完全没有 token → 返回配置恢复提示;缺凭证不是余额不足,不打开充值页
 // - 不主动发计费请求做探测(探测会真扣费);余额不足留给真实取数时由 catalog 的 402 分支处理。
 // - VOC_OPEN_PAYMENT=0 可强制关闭弹窗(CI / 冒烟 / 无头环境)。
 // - 始终把 URL 打印到终端兜底;并追加 &channel=&src=install 做渠道归因。
 // - token 绝不回显到终端 / 日志。
 
 const { spawn } = require('child_process');
-const { readNewApiToken, readVocToken } = require('./credentials');
-const { buildVocRechargeInfo } = require('./payment-links');
+const { readCredentialContext } = require('./credentials');
 
 function appendAttribution(rawUrl, channel, src) {
   try {
@@ -60,45 +59,19 @@ async function ensureActivated({ channel = 'external', src = 'install' } = {}) {
     return { status: 'skipped' };
   }
 
-  const newApiToken = readNewApiToken();
-  const vocToken = readVocToken();
+  const credentials = readCredentialContext();
 
-  if (newApiToken || vocToken) {
+  if (credentials.newApiToken || credentials.sessionToken) {
     console.log('');
     console.log('✅ 已激活:检测到 VOC 计费/会话 token,可直接开始取数,无需充值。');
     console.log('(若取数时返回 402 余额不足,再按工具提示充值即可。)');
     return { status: 'activated' };
   }
 
-  // 完全没有 token:现场新用户。打开充值/登录页,引导先付费再进流程。
-  // 无 token 时 buildVocRechargeInfo 不会发起网络请求(resolveVocUserId 对空 token 直接返回)。
-  let recharge;
-  try {
-    recharge = await buildVocRechargeInfo({ token: '' });
-  } catch {
-    recharge = {};
-  }
-  const primaryUrl = appendAttribution(
-    recharge.recommendedPackageUrl || recharge.paymentUrl || 'https://app.fmode.cn/dev/apig-pay/',
-    channel,
-    src
-  );
-
   console.log('');
-  console.log('⚠️  尚未检测到 VOC token —— 你还没有激活。');
-  console.log('为避免进入取数流程后才发现需要付费、白白消耗 token,请先在浏览器完成登录并充值/开通:');
-  console.log(`  ${primaryUrl}`);
-  if (recharge.paymentUrl) {
-    console.log(`  (充值入口:${appendAttribution(recharge.paymentUrl, channel, src)})`);
-  }
-  console.log('完成付费后,再让 AI 进入取数/报告流程。');
-  console.log('如本机无法自动打开浏览器,请手动复制上面的链接到浏览器打开。');
-
-  const opened = openBrowser(primaryUrl);
-  if (!opened) {
-    console.log('(自动打开浏览器失败,请手动打开上面的链接。)');
-  }
-  return { status: 'needs_activation', url: primaryUrl };
+  console.log('尚未检测到 VOC 调用凭证。请先恢复当前账户的 NewAPI Key 或 Session Token。');
+  console.log('这是凭证配置问题,不是余额不足,因此不会自动打开充值页面。');
+  return { status: 'needs_token', channel, src };
 }
 
 module.exports = { ensureActivated, openBrowser, appendAttribution };

+ 104 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/batch-budget-guard.js

@@ -0,0 +1,104 @@
+'use strict';
+
+const { queryBilling } = require('./billing-client');
+const { checkBudget, buildEstimateBands } = require('./budget-guard');
+
+/**
+ * Re-checks the authenticated balance and actual trace spend before each
+ * collection batch. The query is read-only; NewAPI still charges each real
+ * gateway call independently.
+ */
+function createBatchBudgetGuard({ input = {}, context = {}, operationFactory } = {}) {
+  const budgetLimitCny = input.budgetCny ?? input.budgetLimitCny;
+  const enabled = budgetLimitCny != null || input.enforceBudget === true || input.enforceBudget === 'true';
+  let last = { enabled, status: 'disabled', allowed: true, checks: 0 };
+
+  async function beforeBatch(batch = {}) {
+    if (!enabled) return last;
+    const operations = typeof operationFactory === 'function'
+      ? operationFactory(batch)
+      : [];
+    if (!Array.isArray(operations) || operations.length === 0) return last;
+
+    let result;
+    try {
+      const query = typeof input.queryBillingImpl === 'function' ? input.queryBillingImpl : queryBilling;
+      result = await query({
+        ...input,
+        operations,
+        include: ['balance', 'usage'],
+        usageTraceId: context.usageTraceId,
+        reportId: context.reportId,
+        usageFilter: {
+          ...(input.usageFilter || {}),
+          usageTraceId: context.usageTraceId,
+          ...(context.reportId ? { reportId: context.reportId } : {}),
+          groupBy: ['usageTraceId'],
+          page: 1,
+          pageSize: 1
+        },
+        createPaymentQr: false
+      });
+    } catch (error) {
+      last = {
+        enabled,
+        status: 'unknown',
+        allowed: true,
+        checks: last.checks + 1,
+        warning: `批次预算检查暂时不可用:${error.message || String(error)}`
+      };
+      return last;
+    }
+
+    if (result?.status !== 'ok') {
+      last = {
+        enabled,
+        status: 'unknown',
+        allowed: true,
+        checks: last.checks + 1,
+        warning: result?.assistantMessage || '批次预算检查暂时不可用'
+      };
+      return last;
+    }
+
+    const estimate = result.data?.estimate || {};
+    const usage = result.data?.usage?.totals || {};
+    const bands = buildEstimateBands(estimate, { lowFactor: 1, highFactor: 1 });
+    const budget = checkBudget({
+      estimate,
+      bands,
+      balance: result.data?.balance || {},
+      budgetLimitCny,
+      actualCostCny: usage.costCny || 0,
+      nextCostCny: estimate.amountCny || 0,
+      rechargeBufferCny: input.rechargeBufferCny
+    });
+    last = {
+      ...budget,
+      enabled,
+      checks: last.checks + 1,
+      batchIndex: Number(batch.batchIndex || 0),
+      keyword: batch.keyword || null,
+      usageTraceId: context.usageTraceId,
+      operations
+    };
+    return last;
+  }
+
+  beforeBatch.getState = () => ({ ...last });
+  return { beforeBatch, snapshot: () => ({ ...last }) };
+}
+
+function findBudgetIssue(errors = []) {
+  return errors.find(error => String(error?.kind || error?.errorKind || '') === 'budget') || null;
+}
+
+function budgetStoppedMessage(issue = {}) {
+  const budget = issue.budget || issue;
+  if (budget.status === 'needs_recharge') {
+    return `批次预算检查发现当前余额不足,已停止新增调用。预计本批需 ¥${Number(budget.nextCostCny || 0).toFixed(2)},当前缺口约 ¥${Number(budget.shortfallCny || 0).toFixed(2)}。`;
+  }
+  return `批次预算检查已阻止继续执行,避免超过本地预算上限 ¥${Number(budget.budgetLimitCny || 0).toFixed(2)}。`;
+}
+
+module.exports = { createBatchBudgetGuard, findBudgetIssue, budgetStoppedMessage };

+ 173 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/billing-client.js

@@ -0,0 +1,173 @@
+'use strict';
+
+const { readCredentialContext } = require('./credentials');
+const { redactSecret } = require('../providers/voc-gateway');
+const { buildRechargePaymentRequest, requestPaymentQr } = require('./recharge-client');
+
+const DEFAULT_BILLING_ROOT = 'https://server.fmode.cn';
+
+function resolveBillingRoot(baseUrl) {
+  return String(
+    baseUrl || process.env.VOC_BILLING_BASE_URL || DEFAULT_BILLING_ROOT
+  ).replace(/\/$/, '');
+}
+
+async function queryBilling(input = {}) {
+  const credentials = readCredentialContext(input);
+  const token = credentials.newApiToken || credentials.sessionToken;
+  if (!token) {
+    return {
+      status: 'needs_token',
+      assistantMessage: '缺少 NewAPI Key 或当前用户 Session Token,暂时不能查询计费价目和余额。',
+      summary: { errorKind: 'missing_token', recoverable: true },
+      data: {},
+      recharge: null,
+      files: [],
+      nextActions: ['配置 NewAPI Key 或 Session Token 后重新查询'],
+      warnings: [],
+      errors: []
+    };
+  }
+
+  const operations = Array.isArray(input.operations) ? input.operations : [];
+  const include = input.include
+    ? (Array.isArray(input.include) ? input.include : [input.include])
+    : (input.usageOnly ? ['balance', 'usage'] : ['balance', 'pricing']);
+  const pricingFilter = input.pricingFilter || {
+    ...(input.channels ? { channels: input.channels } : {}),
+    ...(input.endpointIds ? { endpointIds: input.endpointIds } : {}),
+    ...(input.models ? { models: input.models } : {}),
+    ...(input.apigIds ? { apigIds: input.apigIds } : {})
+  };
+  const usageFilter = input.usageFilter || {
+    ...(input.from ? { from: input.from } : {}),
+    ...(input.to ? { to: input.to } : {}),
+    ...(input.usageTraceId ? { usageTraceId: input.usageTraceId } : {}),
+    ...(input.reportId ? { reportId: input.reportId } : {}),
+    ...(input.groupBy ? { groupBy: input.groupBy } : {}),
+    ...(input.page ? { page: input.page } : {}),
+    ...(input.pageSize || input.limit ? { pageSize: input.pageSize || input.limit } : {})
+  };
+  const body = {
+    operations,
+    ...(input.rechargeBufferCny !== undefined ? { rechargeBufferCny: input.rechargeBufferCny } : {}),
+    ...(include ? { include } : {}),
+    ...(Object.keys(pricingFilter).length ? { pricingFilter } : {}),
+    ...(Object.keys(usageFilter).length ? { usageFilter } : {})
+  };
+  const url = `${resolveBillingRoot(input.baseUrl)}/api/fmode/billing/query`;
+  let response;
+  let payload;
+  try {
+    response = await fetch(url, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        Accept: 'application/json',
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify(body)
+    });
+    payload = await response.json().catch(() => ({}));
+  } catch (error) {
+    const message = redactSecret(error?.message || 'billing query request failed');
+    return {
+      status: 'upstream_unstable',
+      assistantMessage: `计费查询服务暂时不可用:${message}`,
+      summary: { errorKind: 'upstream', httpStatus: 0 },
+      data: {},
+      recharge: null,
+      files: [],
+      nextActions: ['稍后重试计费查询'],
+      warnings: [],
+      errors: [{ message, kind: 'upstream', httpStatus: 0 }]
+    };
+  }
+
+  if (!response.ok || Number(payload?.code) >= 400) {
+    const status = Number(payload?.code || response.status) || 500;
+    const kind = status === 401 ? 'auth' : status === 402 ? 'billing' : status === 403 ? 'permission' : 'request';
+    const message = redactSecret(payload?.message || payload?.mess || `HTTP ${status}`);
+    return {
+      status: kind === 'billing' ? 'needs_recharge' : kind === 'auth' ? 'needs_valid_token' : kind === 'permission' ? 'needs_permission' : 'needs_input',
+      assistantMessage: message,
+      summary: { errorKind: kind, httpStatus: status },
+      data: payload?.data || {},
+      recharge: payload?.data?.balanceUrl ? { paymentMode: 'balance_url', balanceUrl: payload.data.balanceUrl } : null,
+      files: [],
+      nextActions: [],
+      warnings: [],
+      errors: [{ message, kind, httpStatus: status }]
+    };
+  }
+
+  const data = payload?.data || payload;
+  const estimate = data.estimate || {};
+  let paymentRequest = null;
+  let qrCodeUrl = null;
+  let qrError = null;
+  if (!estimate.sufficient && credentials.sessionToken && Number(estimate.suggestedRechargeCny) > 0) {
+    paymentRequest = buildRechargePaymentRequest({
+      amount: estimate.suggestedRechargeCny,
+      title: input.title || 'VOC 数据调用充值',
+      sessionToken: credentials.sessionToken
+    });
+    // Do not expose the Session Token in MCP output. The caller can use the
+    // Tokenized Balance URL, while an in-process payment client keeps the
+    // header private when it invokes pay_code2.
+    delete paymentRequest.headers;
+    if (input.createPaymentQr === true) {
+      try {
+        const payment = await requestPaymentQr({
+          amount: estimate.suggestedRechargeCny,
+          title: input.title || 'VOC 数据调用充值',
+          sessionToken: credentials.sessionToken,
+          tradeNo: paymentRequest.tradeNo
+        });
+        qrCodeUrl = payment.qrCodeUrl;
+        paymentRequest = {
+          ...paymentRequest,
+          qrCodeUrl: payment.qrCodeUrl,
+          nonceStr: payment.nonceStr,
+          polling: payment.polling
+        };
+      } catch (error) {
+        qrError = redactSecret(error?.message || 'payment QR request failed');
+      }
+    }
+  }
+  const recharge = paymentRequest
+    ? {
+      paymentMode: qrCodeUrl ? 'qr_and_balance_url' : 'balance_url_and_payment_request',
+      balanceUrl: data.balanceUrl || null,
+      ...(qrCodeUrl ? { qrCodeUrl } : {}),
+      paymentRequest,
+      polling: 'order_status2'
+    }
+    : (data.balanceUrl ? { paymentMode: 'balance_url', balanceUrl: data.balanceUrl } : null);
+  const lines = [
+    `预计 ${estimate.amountCny ?? 0} 元,需 ${estimate.quota ?? 0} quota;当前可用 ${data.balance?.availableQuota ?? 0} quota。`,
+    estimate.sufficient
+      ? '当前余额足够执行该计划。'
+      : `余额差额 ${estimate.shortfallQuota ?? 0} quota,建议充值 ${estimate.suggestedRechargeCny ?? 0} 元。`,
+    data.balanceUrl ? `免登录余额页:${data.balanceUrl}` : ''
+  ].filter(Boolean);
+  return {
+    status: 'ok',
+    assistantMessage: lines.join('\n'),
+    summary: {
+      operationCount: estimate.operations?.length || 0,
+      quota: estimate.quota,
+      amountCny: estimate.amountCny,
+      sufficient: estimate.sufficient
+    },
+    data,
+    recharge,
+    files: [],
+    nextActions: estimate.sufficient ? [] : ['打开余额页完成充值后重新查询并继续原计划'],
+    warnings: qrError ? [`支付二维码生成失败,已保留免登录充值页:${qrError}`] : [],
+    errors: []
+  };
+}
+
+module.exports = { DEFAULT_BILLING_ROOT, resolveBillingRoot, queryBilling };

+ 115 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/budget-guard.js

@@ -0,0 +1,115 @@
+'use strict';
+
+function number(value, fallback = 0) {
+  const n = Number(value);
+  return Number.isFinite(n) ? n : fallback;
+}
+
+function round(value, digits = 2) {
+  const factor = 10 ** digits;
+  return Math.round(number(value) * factor) / factor;
+}
+
+function calculateCostFromOperations(operations = []) {
+  return operations.reduce((result, operation = {}) => {
+    const count = Math.max(0, number(operation.count, 1));
+    const price = number(operation.priceCnyPerCall ?? operation.unitPriceCny ?? operation.priceCny);
+    const quota = number(operation.quota ?? operation.quotaPerCall * count);
+    const amount = number(operation.amountCny, price * count);
+    result.quota += quota;
+    result.amountCny += amount;
+    result.calls += count;
+    return result;
+  }, { quota: 0, amountCny: 0, calls: 0 });
+}
+
+function buildEstimateBands(expected, options = {}) {
+  const base = typeof expected === 'object' ? expected : { amountCny: expected };
+  const expectedCny = Math.max(0, number(base.amountCny ?? base.expectedCny));
+  const expectedQuota = Math.max(0, number(base.quota ?? base.expectedQuota));
+  const lowFactor = Math.max(0, number(options.lowFactor, 0.8));
+  const highFactor = Math.max(lowFactor, number(options.highFactor, 1.25));
+  return {
+    lowCny: round(expectedCny * lowFactor),
+    expectedCny: round(expectedCny),
+    highCny: round(expectedCny * highFactor),
+    lowQuota: Math.round(expectedQuota * lowFactor),
+    expectedQuota: Math.round(expectedQuota),
+    highQuota: Math.round(expectedQuota * highFactor)
+  };
+}
+
+function balanceToCny(balance = {}, estimate = {}) {
+  if (Number.isFinite(Number(balance.availableCny))) return Math.max(0, Number(balance.availableCny));
+  if (Number.isFinite(Number(balance.balanceCny))) return Math.max(0, Number(balance.balanceCny));
+  const quota = number(balance.availableQuota);
+  const ratio = number(estimate.amountCny) / number(estimate.quota);
+  return ratio > 0 ? quota * ratio : null;
+}
+
+function suggestedRecharge(shortfallCny, options = {}) {
+  const shortfall = Math.max(0, number(shortfallCny));
+  const buffer = Math.max(0, number(options.bufferCny ?? options.rechargeBufferCny));
+  const minimum = Math.max(0, number(options.minimumCny));
+  if (!shortfall && !minimum) return 0;
+  return round(Math.max(minimum, shortfall + buffer));
+}
+
+function checkBudget(options = {}) {
+  const estimate = options.estimate || {};
+  const bands = options.bands || buildEstimateBands(estimate, options);
+  const actualCostCny = Math.max(0, number(options.actualCostCny));
+  const nextCostCny = Math.max(0, number(options.nextCostCny ?? bands.expectedCny));
+  const projectedCny = round(actualCostCny + nextCostCny);
+  const budgetLimitCny = options.budgetLimitCny == null ? null : Math.max(0, number(options.budgetLimitCny));
+  const balanceCny = balanceToCny(options.balance || {}, estimate);
+  const availableQuota = Number.isFinite(Number(options.balance?.availableQuota)) ? Math.max(0, number(options.balance.availableQuota)) : null;
+  const balanceInsufficient = balanceCny !== null && balanceCny < nextCostCny;
+  const budgetRatio = budgetLimitCny === null || budgetLimitCny === 0 ? null : projectedCny / budgetLimitCny;
+  const hardLimit = budgetLimitCny !== null && projectedCny > budgetLimitCny;
+  const warning = budgetLimitCny !== null && !hardLimit && budgetRatio >= number(options.warningRatio, 0.8);
+  const shortfallCny = balanceInsufficient ? round(nextCostCny - balanceCny) : 0;
+  let status = 'ok';
+  if (hardLimit) status = 'blocked';
+  else if (balanceInsufficient) status = 'needs_recharge';
+  else if (warning) status = 'warning';
+  return {
+    status,
+    allowed: status !== 'blocked' && status !== 'needs_recharge',
+    warning,
+    budgetLimitCny,
+    actualCostCny: round(actualCostCny),
+    nextCostCny: round(nextCostCny),
+    projectedCny,
+    remainingBudgetCny: budgetLimitCny === null ? null : round(Math.max(0, budgetLimitCny - actualCostCny)),
+    balanceCny: balanceCny === null ? null : round(balanceCny),
+    shortfallCny,
+    shortfallQuota: availableQuota === null ? null : Math.max(0, Math.ceil(number(bands.highQuota) - availableQuota)),
+    suggestedRechargeCny: suggestedRecharge(shortfallCny, options),
+    bands
+  };
+}
+
+function buildCheckpoint(context = {}, progress = {}) {
+  return {
+    usageTraceId: context.usageTraceId || null,
+    reportId: context.reportId || null,
+    stepId: context.stepId || null,
+    nextStepId: progress.nextStepId || null,
+    completed: Array.isArray(progress.completed) ? progress.completed.slice(0, 10000) : [],
+    pending: Array.isArray(progress.pending) ? progress.pending.slice(0, 10000) : [],
+    cursor: progress.cursor || null,
+    savedAt: new Date().toISOString()
+  };
+}
+
+module.exports = {
+  number,
+  round,
+  calculateCostFromOperations,
+  buildEstimateBands,
+  balanceToCny,
+  suggestedRecharge,
+  checkBudget,
+  buildCheckpoint
+};

+ 80 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/credentials.js

@@ -15,6 +15,25 @@ function firstNonEmpty(values) {
   return values.find(value => typeof value === 'string' && value.trim()) || '';
 }
 
+function isNewApiToken(value) {
+  const token = String(value || '').trim();
+  return /^sk-/i.test(token) && !/^sk-ant-/i.test(token);
+}
+
+function isSessionToken(value) {
+  return /^r:[A-Za-z0-9_-]{8,}$/.test(String(value || '').trim());
+}
+
+function firstTypedCredential(entries, predicate) {
+  for (const entry of entries) {
+    const value = typeof entry.value === 'string' ? entry.value.trim() : '';
+    if (value && predicate(value)) {
+      return { value, source: entry.source };
+    }
+  }
+  return { value: '', source: 'none' };
+}
+
 function readEnvFileMaybe(filePath) {
   try {
     if (!filePath || !fs.existsSync(filePath)) return {};
@@ -140,6 +159,36 @@ function readXiaohongshuToken(input = {}) {
   return readVocToken(input);
 }
 
+function readSessionCredential(input = {}) {
+  const workspaceEnv = readEnvLocalFiles();
+  const claudeCreds = readJsonMaybe(path.join(os.homedir(), '.claude', 'voc-credentials.json'));
+  return firstTypedCredential([
+    { value: input.sessionToken, source: 'request.sessionToken' },
+    { value: input.tihaoToken, source: 'request.tihaoToken' },
+    { value: input.vocToken, source: 'request.vocToken' },
+    { value: input.xiaohongshuToken, source: 'request.xiaohongshuToken' },
+    { value: input.douyinToken, source: 'request.douyinToken' },
+    { value: input['voc-token'], source: 'request.voc-token' },
+    { value: input.token, source: 'request.token' },
+    { value: input.apiToken, source: 'request.apiToken' },
+    { value: workspaceEnv.FMODE_SESSION_TOKEN, source: '.env.local.FMODE_SESSION_TOKEN' },
+    { value: workspaceEnv.TIHAO_SESSION_TOKEN, source: '.env.local.TIHAO_SESSION_TOKEN' },
+    { value: workspaceEnv.VOC_TOKEN, source: '.env.local.VOC_TOKEN' },
+    { value: workspaceEnv.VOC_SOCIAL_TOKEN, source: '.env.local.VOC_SOCIAL_TOKEN' },
+    { value: process.env.FMODE_SESSION_TOKEN, source: 'env.FMODE_SESSION_TOKEN' },
+    { value: process.env.TIHAO_SESSION_TOKEN, source: 'env.TIHAO_SESSION_TOKEN' },
+    { value: process.env.VOC_TOKEN, source: 'env.VOC_TOKEN' },
+    { value: process.env.VOC_SOCIAL_TOKEN, source: 'env.VOC_SOCIAL_TOKEN' },
+    { value: claudeCreds.sessionToken, source: '~/.claude/voc-credentials.json.sessionToken' },
+    { value: claudeCreds.vocToken, source: '~/.claude/voc-credentials.json.vocToken' },
+    { value: claudeCreds.token, source: '~/.claude/voc-credentials.json.token' }
+  ], isSessionToken);
+}
+
+function readSessionToken(input = {}) {
+  return readSessionCredential(input).value;
+}
+
 // NewAPI(fmode-api)计费 token,以 sk- 开头。
 // 计费已迁移到 NewAPI:优先用它发请求,鉴权失败再回退平台 sessionToken(apig,原路径保留)。
 // 关键:这把 sk- 就是 Claude Code 的 env.ANTHROPIC_AUTH_TOKEN(在 ~/.claude/settings.json),
@@ -185,10 +234,41 @@ function readNewApiToken(input = {}) {
   ]);
 }
 
+function readCredentialContext(input = {}) {
+  const rawNewApiToken = readNewApiToken(input);
+  const rawVocToken = readVocToken(input);
+  const sessionCredential = readSessionCredential(input);
+  const newApiToken = isNewApiToken(rawNewApiToken)
+    ? rawNewApiToken.trim()
+    : (isNewApiToken(rawVocToken) ? rawVocToken.trim() : '');
+  const newApiSource = isNewApiToken(rawNewApiToken)
+    ? 'newapi-resolution'
+    : (isNewApiToken(rawVocToken) ? 'voc-token-alias' : 'none');
+
+  return {
+    newApiToken,
+    sessionToken: sessionCredential.value,
+    sources: {
+      newApiToken: newApiSource,
+      sessionToken: sessionCredential.source
+    },
+    capabilities: {
+      canCallWithNewApi: Boolean(newApiToken),
+      canCallWithSessionIdentity: Boolean(sessionCredential.value),
+      canBuildPersonalBalanceUrl: Boolean(sessionCredential.value),
+      canCreatePaymentOrder: Boolean(sessionCredential.value)
+    }
+  };
+}
+
 module.exports = {
   readVocToken,
   readXiaohongshuToken,
   readNewApiToken,
+  readSessionToken,
+  readCredentialContext,
+  isNewApiToken,
+  isSessionToken,
   readEnvFileMaybe,
   readWorkspaceEnvLocal,
   envLocalCandidates,

+ 72 - 138
claude-code/claude-code-voc-intelligence/mcp/src/core/payment-links.js

@@ -1,187 +1,121 @@
-const VOC_SOCIAL_APIG_ID = 'Vo3ROWEvDy';
-const VOC_RECHARGE_FUN_ID = 'HOkkX72PMF';
-const VOC_RECHARGE_BASE_URL = 'https://app.fmode.cn/dev/apig-pay/';
-const PARSE_USERS_ME_URL = 'https://server.fmode.cn/parse/users/me';
+const { isSessionToken } = require('./credentials');
 
-// NewAPI(fmode-api)余额充值入口。携带 ?balance=fmodeapi 会自动打开余额充值弹窗。
-const FMODE_API_RECHARGE_URL = 'https://app.fmode.cn/dev/studio/?balance=fmodeapi';
-const DEFAULT_WORKSHOP_PACKAGE_SLUG = '99';
-const VOC_WORKSHOP_PACKAGE_SLUGS = {
-  trial199: '19-9',
-  trial299: '29-9',
-  selfPractice: '99',
-  onsiteTraining: '299',
-  teamDelivery: '699'
-};
+const FMODE_API_BALANCE_BASE_URL = 'https://app.fmode.cn/dev/studio/balance/';
 
-function buildVocRechargeUrl(options = {}) {
-  const url = new URL(VOC_RECHARGE_BASE_URL);
-  if (options.userId) {
-    url.searchParams.set('user', String(options.userId));
-  }
-  url.searchParams.set('apigid', VOC_SOCIAL_APIG_ID);
-  url.searchParams.set('fun_id', VOC_RECHARGE_FUN_ID);
+function buildTokenizedBalanceUrl(sessionToken) {
+  const token = String(sessionToken || '').trim();
+  if (!isSessionToken(token)) return '';
+  const url = new URL(FMODE_API_BALANCE_BASE_URL);
+  url.searchParams.set('token', token);
   return url.toString();
 }
 
-function buildVocWorkshopUrl(options = {}) {
-  const url = new URL(VOC_RECHARGE_BASE_URL);
-  if (options.userId) {
-    url.searchParams.set('user', String(options.userId));
+function resolveSessionToken(options = {}) {
+  if (typeof options === 'string') return isSessionToken(options) ? options.trim() : '';
+  for (const value of [options.sessionToken, options.token, options.vocToken]) {
+    if (isSessionToken(value)) return String(value).trim();
   }
-  const slug = options.slug ? String(options.slug) : '';
-  url.hash = slug ? `/workshop/${slug}` : '/workshop';
-  return url.toString();
+  return '';
 }
 
-async function resolveVocUserId(token) {
-  if (!token) return '';
-  try {
-    const response = await fetch(PARSE_USERS_ME_URL, {
-      method: 'GET',
-      headers: {
-        'X-Parse-Application-Id': 'ncloudmaster',
-        'X-Parse-Session-Token': token,
-        Accept: 'application/json'
-      }
-    });
-    if (!response.ok) return '';
-    const json = await response.json();
-    return json && json.objectId ? String(json.objectId) : '';
-  } catch {
-    return '';
-  }
+function buildFmodeApiRechargeUrl(options = {}) {
+  return buildTokenizedBalanceUrl(resolveSessionToken(options)) || FMODE_API_BALANCE_BASE_URL;
 }
 
 async function buildVocRechargeInfo(options = {}) {
-  const userId = await resolveVocUserId(options.token);
-  const workshopUrl = buildVocWorkshopUrl({ userId });
-  const recommendedPackageUrl = buildVocWorkshopUrl({
-    userId,
-    slug: options.packageSlug || DEFAULT_WORKSHOP_PACKAGE_SLUG
-  });
+  const sessionToken = resolveSessionToken(options);
+  const balanceUrl = buildTokenizedBalanceUrl(sessionToken);
   return {
-    paymentUrl: buildVocRechargeUrl({ userId }),
-    fallbackPaymentUrl: buildVocRechargeUrl(),
-    workshopUrl,
-    recommendedPackageUrl,
-    packageUrls: {
-      trial199: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.trial199 }),
-      trial299: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.trial299 }),
-      selfPractice: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.selfPractice }),
-      onsiteTraining: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.onsiteTraining }),
-      teamDelivery: buildVocWorkshopUrl({ userId, slug: VOC_WORKSHOP_PACKAGE_SLUGS.teamDelivery })
-    },
-    apigId: VOC_SOCIAL_APIG_ID,
-    funId: VOC_RECHARGE_FUN_ID,
-    personalized: Boolean(userId)
+    paymentMode: balanceUrl ? 'balance_url' : 'session_required',
+    paymentUrl: balanceUrl,
+    balanceUrl,
+    personalized: Boolean(balanceUrl),
+    requiresSession: !balanceUrl,
+    billingAccount: 'newapi',
+    ...(Number.isFinite(Number(options.shortageCny)) ? { shortageCny: Number(options.shortageCny) } : {}),
+    ...(Number.isFinite(Number(options.suggestedRechargeCny)) ? { suggestedRechargeCny: Number(options.suggestedRechargeCny) } : {}),
+    ...(options.paymentRequest && typeof options.paymentRequest === 'object'
+      ? { paymentRequest: options.paymentRequest }
+      : {})
   };
 }
 
-function buildMissingTokenMessage(paymentUrl, recharge = {}, options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
-  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+function buildMissingTokenMessage(_paymentUrl, _recharge = {}, options = {}) {
+  const platformLabel = options.platformLabel || 'VOC';
   return [
-    `还没有检测到 VOC-AI 数据服务 token,暂时不能进行真实 ${platformLabel} 采集。`,
+    `还没有检测到可用于 ${platformLabel} 真实采集的账户凭证。`,
     '',
-    `请先打开充值/开通链接:${paymentUrl}`,
+    '这是凭证配置问题,不是余额不足。请先恢复当前账户的 NewAPI Key 或 Session Token,再重新运行。',
     '',
-    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
-    '',
-    '完成充值或开通后,把服务方提供的 session token 配置到当前工作区 `.env.local` 或 `~/.claude/voc-credentials.json`,再重新运行 live 采集。',
-    '',
-    '系统只会用 token 发起请求,不会在报告、日志或聊天输出里回显 token。'
+    '系统不会在报告、日志或普通聊天内容中回显凭证。'
   ].join('\n');
 }
 
 function buildRechargeRequiredMessage(paymentUrl, recharge = {}, options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
-  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
-  return [
-    `VOC-AI 数据服务余额不足,暂时不能继续真实 ${platformLabel} 采集。`,
-    '',
-    `请先打开充值链接补充额度:${paymentUrl}`,
-    '',
-    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
-    '',
-    `我已经隐藏了底层接口权限/余额报错,不会把真实 ${platformLabel} 接口 403 或权限信息直接展示给用户。`
-  ].join('\n');
+  const platformLabel = options.platformLabel || 'VOC';
+  const url = recharge.balanceUrl || paymentUrl || '';
+  const lines = [
+    `${platformLabel} 数据调用检测到 NewAPI 余额不足,当前任务已停止继续扣费并保留已有结果。`,
+  ];
+  const shortageCny = Number(recharge.shortageCny);
+  const suggestedRechargeCny = Number(recharge.suggestedRechargeCny);
+  if (Number.isFinite(shortageCny) || Number.isFinite(suggestedRechargeCny)) {
+    const shortage = Number.isFinite(shortageCny) ? `还差 ¥${shortageCny.toFixed(2)}` : '';
+    const suggested = Number.isFinite(suggestedRechargeCny) ? `建议充值 ¥${suggestedRechargeCny.toFixed(2)}` : '';
+    lines.push('', [shortage, suggested].filter(Boolean).join(',') + '。');
+  }
+  if (url && recharge.personalized) {
+    lines.push('', `免登录充值入口:${url}`, '', '该链接已携带当前用户身份,打开后可直接选择金额并支付,无需重新登录。');
+  } else {
+    lines.push('', '当前 Agent 没有可用于免登录付费的 Session Token。请先恢复当前用户会话,再生成个人充值链接或支付二维码。');
+  }
+  lines.push('', '充值到账后使用相同参数继续任务即可。');
+  return lines.join('\n');
 }
 
-function buildWrongTokenTypeMessage(paymentUrl, recharge = {}, options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
-  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
+function buildWrongTokenTypeMessage(_paymentUrl, _recharge = {}, options = {}) {
+  const platformLabel = options.platformLabel || 'VOC';
   return [
-    `检测到配置的是 sk- 开头的 AIGate API Key,这不是 ${platformLabel} 数据采集所需的 token,暂时不能进行真实采集。`,
-    '',
-    '这不是关键词或类目问题:任何关键词(含律师、法律、专业服务等)都可以采集,只要 token 类型正确并已开通额度。',
-    '',
-    `${platformLabel} 采集需要从充值/开通页复制的、以 r: 开头的会话 token(session token),不是 sk- 开头的 AIGate Key。`,
+    `当前配置的凭证类型不适用于 ${platformLabel} 数据调用。`,
     '',
-    `请打开充值/开通链接:${paymentUrl}`,
-    '',
-    `课程或工作坊用户也可以打开套餐入口:${packageUrl}`,
-    '',
-    '把以 r: 开头的 session token 配置到当前工作区 `.env.local` 或 `~/.claude/voc-credentials.json`(键名 vocToken / VOC_TOKEN),再重新运行 live 采集。'
+    '请恢复有效的 NewAPI Key 或当前用户 Session Token 后重试。',
+    '这不是余额不足,当前不会引导充值。'
   ].join('\n');
 }
 
-// 没读到 NewAPI(fmode-api)的 sk- 计费 token 时的提示。
-// 关键:这是「没读到 SK」这种**可恢复**状态,不是余额不足、也不是功能用不了——
-// 先引导 agent 从 Claude Code 的 settings.json 取 SK 重试,充值只作为最后兜底(且仅真 402 才需要)。
 function buildMissingNewApiTokenMessage(paymentUrl, recharge = {}, options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
+  const platformLabel = options.platformLabel || 'VOC';
   const lines = [
-    `还没读到 NewAPI(fmode-api)计费 token,暂时不能进行真实 ${platformLabel} 采集——但这是**配置/读取**问题,不是余额不足,也不代表这个功能用不了。`,
-    '',
-    '这把计费 token 就是 Claude Code 的 `ANTHROPIC_AUTH_TOKEN`(`sk-` 开头),通常配在用户级 `~/.claude/settings.json` 的 `env` 里。',
+    `还没读到可用于 ${platformLabel} 真实采集的 NewAPI Key 或 Session Token。`,
     '',
-    '请按这个顺序自救(优先,不要直接让用户去点充值):',
-    '1. 读取 `~/.claude/settings.json` 的 `env.ANTHROPIC_AUTH_TOKEN`(确认是 `sk-` 开头、`ANTHROPIC_BASE_URL` 指向 `api.fmode.cn`)。',
-    '2. 用 `FMODE_API_KEY=sk-…`(或入参 `newapiToken`)把它传给本工具,重新调用一次。',
-    '3. 仍报缺 token / 鉴权失败时,才考虑回退 `r:` 开头的会话 token,或确认是否真的 `402 余额不足`。'
+    '这是配置/读取问题,不是余额不足。优先从 Claude Code 设置、Fmode 配置或当前登录会话恢复凭证后重试。'
   ];
-  // 这是 newapi(fmode-api)计费链,兜底充值必须指向 fmode-api 余额充值入口
-  // (studio/?balance=fmodeapi),不是 VOC 采集会话 token 的 apig-pay 链接。
-  const fmodeApiRechargeUrl = options.fmodeApiRechargeUrl || FMODE_API_RECHARGE_URL;
-  lines.push('', `(仅当确认是 fmode-api 余额不足时才充值:${fmodeApiRechargeUrl})`);
-  lines.push('', '系统只会用 token 发起请求,绝不在报告、日志或聊天输出里回显 token。');
+  if (recharge.personalized) {
+    lines.push('', `当前用户余额页:${recharge.balanceUrl || paymentUrl}`);
+  }
+  lines.push('', '系统只会在 HTTPS 认证请求和明确交付给当前用户的免登录链接中使用凭证,不会写入报告或日志。');
   return lines.join('\n');
 }
 
-function buildFmodeApiRechargeUrl() {
-  return FMODE_API_RECHARGE_URL;
-}
-
-// NewAPI(fmode-api)余额不足(HTTP 402)时的充值提示。
-function buildFmodeApiRechargeMessage(paymentUrl = FMODE_API_RECHARGE_URL, options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
-  return [
-    `VOC-AI 数据服务余额不足(fmode-api 计费额度已用尽),暂时不能继续真实 ${platformLabel} 采集。`,
-    '',
-    `请打开充值链接补充余额:${paymentUrl}`,
-    '',
-    '链接会自动打开余额充值弹窗;充值后用相同参数重试即可。底层鉴权/上游响应不会展示给用户。'
-  ].join('\n');
+function buildFmodeApiRechargeMessage(paymentUrl, options = {}) {
+  const platformLabel = options.platformLabel || 'VOC';
+  const recharge = options.recharge || {};
+  return buildRechargeRequiredMessage(paymentUrl || recharge.paymentUrl, recharge, { platformLabel });
 }
 
-// 用户被禁用或无该接口权限(HTTP 403,区别于余额不足)。
 function buildPermissionDeniedMessage(options = {}) {
-  const platformLabel = options.platformLabel || '小红书';
+  const platformLabel = options.platformLabel || 'VOC';
   return [
-    `当前账号被禁用或没有该 ${platformLabel} 接口的访问权限,暂时不能调用(这不是余额不足、也不是关键词/类目问题)。`,
+    `当前账号被禁用或没有该 ${platformLabel} 接口的访问权限。`,
     '',
-    '请联系服务方确认账号状态或接口权限后重试。底层鉴权/上游响应不会展示给用户。'
+    '这是权限问题,不是余额不足。请确认账号状态或接口权限后重试。'
   ].join('\n');
 }
 
 module.exports = {
-  VOC_SOCIAL_APIG_ID,
-  VOC_RECHARGE_FUN_ID,
-  VOC_WORKSHOP_PACKAGE_SLUGS,
+  FMODE_API_BALANCE_BASE_URL,
+  buildTokenizedBalanceUrl,
   buildVocRechargeInfo,
-  buildVocRechargeUrl,
-  buildVocWorkshopUrl,
   buildMissingTokenMessage,
   buildMissingNewApiTokenMessage,
   buildRechargeRequiredMessage,

+ 81 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/recharge-client.js

@@ -0,0 +1,81 @@
+'use strict';
+
+const { randomBytes } = require('crypto');
+
+const DEFAULT_PARSE_FUNCTIONS_URL = 'https://server.fmode.cn/parse/functions';
+const DEFAULT_PAY_COMPANY = '1AiWpTEDH9';
+const DEFAULT_NEW_API_CALLBACK = 'WbyPrzMI3A';
+
+function createTradeNo(prefix = 'VOC') {
+  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '');
+  return `${prefix}${stamp}${randomBytes(4).toString('hex')}`;
+}
+
+function buildRechargePaymentRequest({ amount, title = 'VOC 数据调用充值', tradeNo, sessionToken } = {}) {
+  const numericAmount = Number(amount);
+  if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
+    throw new Error('充值金额必须大于 0');
+  }
+  const outTradeNo = tradeNo || createTradeNo();
+  const parseFunctionsUrl = String(process.env.FMODE_PARSE_FUNCTIONS_URL || DEFAULT_PARSE_FUNCTIONS_URL).replace(/\/$/, '');
+  const body = {
+    company: process.env.FMODE_PAY_COMPANY || DEFAULT_PAY_COMPANY,
+    out_trade_no: outTradeNo,
+    total_fee: Number(numericAmount.toFixed(2)),
+    body: String(title).slice(0, 80),
+    fun_id: process.env.FMODE_NEWAPI_RECHARGE_CALLBACK_FUN_ID || DEFAULT_NEW_API_CALLBACK
+  };
+  return {
+    endpoint: `${parseFunctionsUrl}/pay_code2`,
+    method: 'POST',
+    headers: sessionToken ? { 'X-Parse-Session-Token': sessionToken } : {},
+    body,
+    tradeNo: outTradeNo,
+    amountCny: body.total_fee,
+    polling: {
+      endpoint: `${parseFunctionsUrl}/order_status2`,
+      method: 'POST',
+      body: {
+        out_trade_no: outTradeNo,
+        company: body.company
+      }
+    }
+  };
+}
+
+async function requestPaymentQr({ amount, title, sessionToken, tradeNo, fetchImpl = fetch } = {}) {
+  const request = buildRechargePaymentRequest({ amount, title, sessionToken, tradeNo });
+  const response = await fetchImpl(request.endpoint, {
+    method: request.method,
+    headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...request.headers },
+    body: JSON.stringify(request.body)
+  });
+  const payload = await response.json().catch(() => ({}));
+  const codeUrl = Array.isArray(payload?.code_url) ? payload.code_url[0] : payload?.code_url;
+  if (!response.ok || !codeUrl) {
+    const error = new Error(payload?.error || payload?.message || `pay_code2 HTTP ${response.status}`);
+    error.httpStatus = response.status;
+    throw error;
+  }
+  const nonceStr = payload?.nonce_str || '';
+  return {
+    ...request,
+    paymentMode: 'qr',
+    qrCodeUrl: String(codeUrl),
+    nonceStr,
+    polling: {
+      ...request.polling,
+      body: {
+        ...request.polling.body,
+        ...(nonceStr ? { nonce_str: nonceStr } : {})
+      }
+    }
+  };
+}
+
+module.exports = {
+  DEFAULT_PARSE_FUNCTIONS_URL,
+  createTradeNo,
+  buildRechargePaymentRequest,
+  requestPaymentQr
+};

+ 11 - 4
claude-code/claude-code-voc-intelligence/mcp/src/core/result-envelope.js

@@ -1,6 +1,6 @@
 function okResult(payload = {}) {
-  return {
-    status: 'ok',
+  const result = {
+    status: payload.status || 'ok',
     assistantMessage: payload.assistantMessage || '',
     summary: payload.summary || {},
     data: payload.data || {},
@@ -9,10 +9,14 @@ function okResult(payload = {}) {
     warnings: payload.warnings || [],
     errors: payload.errors || []
   };
+  for (const key of ['usage', 'estimate', 'recharge', 'checkpoint']) {
+    if (Object.prototype.hasOwnProperty.call(payload, key)) result[key] = payload[key];
+  }
+  return result;
 }
 
 function errorResult(message, payload = {}) {
-  return {
+  const result = {
     status: 'error',
     assistantMessage: message,
     summary: payload.summary || {},
@@ -22,10 +26,13 @@ function errorResult(message, payload = {}) {
     warnings: payload.warnings || [],
     errors: payload.errors || [{ message }]
   };
+  for (const key of ['usage', 'estimate', 'recharge', 'checkpoint']) {
+    if (Object.prototype.hasOwnProperty.call(payload, key)) result[key] = payload[key];
+  }
+  return result;
 }
 
 module.exports = {
   okResult,
   errorResult
 };
-

+ 98 - 0
claude-code/claude-code-voc-intelligence/mcp/src/core/usage-context.js

@@ -0,0 +1,98 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const MAX_LABEL = 120;
+const ID_RE = /^[A-Za-z0-9._:-]{1,120}$/;
+
+function cleanLabel(value, fallback = '') {
+  const text = String(value == null ? '' : value).trim().replace(/[\r\n\t]+/g, ' ');
+  return text.slice(0, MAX_LABEL) || fallback;
+}
+
+function newId(prefix = '') {
+  const id = crypto.randomUUID();
+  return prefix ? `${cleanLabel(prefix).replace(/[^A-Za-z0-9_-]/g, '')}_${id}` : id;
+}
+
+function validId(value) {
+  return ID_RE.test(String(value || '').trim());
+}
+
+function createUsageContext(input = {}) {
+  const usageTraceId = validId(input.usageTraceId) ? String(input.usageTraceId).trim() : newId('usage');
+  const reportId = input.reportId == null || input.reportId === ''
+    ? null
+    : (validId(input.reportId) ? String(input.reportId).trim() : newId('report'));
+  return {
+    usageTraceId,
+    reportId,
+    skillId: cleanLabel(input.skillId, 'voc-api-catalog'),
+    workflowType: cleanLabel(input.workflowType, 'voc_custom_workflow'),
+    title: cleanLabel(input.title || input.task),
+    clientId: cleanLabel(input.clientId),
+    stepId: cleanLabel(input.stepId),
+    budgetLimitCny: Number.isFinite(Number(input.budgetLimitCny ?? input.budgetCny))
+      ? Math.max(0, Number(input.budgetLimitCny ?? input.budgetCny))
+      : null,
+    pricingVersion: cleanLabel(input.pricingVersion),
+    createdAt: input.createdAt || new Date().toISOString()
+  };
+}
+
+function createStepContext(context, stepId, extra = {}) {
+  const base = createUsageContext({ ...context, stepId });
+  return {
+    ...base,
+    requestId: validId(extra.requestId) ? String(extra.requestId).trim() : newId('request'),
+    ...(extra.entityId ? { entityId: cleanLabel(extra.entityId) } : {}),
+    ...(extra.batchIndex !== undefined ? { batchIndex: Number(extra.batchIndex) || 0 } : {})
+  };
+}
+
+function usageHeaders(context = {}) {
+  const headers = {};
+  const values = {
+    'X-Fmode-Usage-Trace-Id': context.usageTraceId,
+    'X-Fmode-Report-Id': context.reportId,
+    'X-Fmode-Skill-Id': context.skillId,
+    'X-Fmode-Workflow-Type': context.workflowType,
+    'X-Fmode-Step-Id': context.stepId,
+    'X-Fmode-Client-Id': context.clientId,
+    'X-Fmode-Request-Id': context.requestId
+  };
+  for (const [name, value] of Object.entries(values)) {
+    if (value && validId(value)) headers[name] = String(value);
+  }
+  return headers;
+}
+
+function contextForTool(input = {}) {
+  return createUsageContext({
+    ...input,
+    budgetLimitCny: input.budgetLimitCny ?? input.budgetCny
+  });
+}
+
+function writeUsageContext(context, outputDir) {
+  if (!outputDir) return null;
+  const dir = path.resolve(String(outputDir));
+  fs.mkdirSync(dir, { recursive: true });
+  const filePath = path.join(dir, 'usage-context.json');
+  fs.writeFileSync(filePath, JSON.stringify(context, null, 2));
+  return filePath;
+}
+
+module.exports = {
+  MAX_LABEL,
+  cleanLabel,
+  newId,
+  validId,
+  createUsageContext,
+  createStepContext,
+  contextForTool,
+  usageHeaders,
+  writeUsageContext
+};

+ 35 - 6
claude-code/claude-code-voc-intelligence/mcp/src/features/douyin-trend/live-collector.js

@@ -269,7 +269,8 @@ async function searchTopVideosWithRepair({ api, keyword, profile, options, video
         keyword,
         retryKeyword,
         message: '抖音搜索入参或上游请求失败,已自动换成更稳的短关键词重试。',
-        httpStatus: error.httpStatus || 0
+        httpStatus: error.httpStatus || 0,
+        kind: error.kind || error.errorKind || ''
       });
       try {
         return {
@@ -284,8 +285,8 @@ async function searchTopVideosWithRepair({ api, keyword, profile, options, video
   }
 }
 
-async function collectLiveDataset({ profile, token, options = {} }) {
-  const api = new DouyinApi({ token, baseUrl: options.baseUrl });
+async function collectLiveDataset({ profile, token, fallbackToken, options = {} }) {
+  const api = new DouyinApi({ token, fallbackToken, baseUrl: options.baseUrl, headers: options.headers });
   const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
   const videosPerKeyword = Math.max(1, Number(options.videosPerKeyword || options.notesPerKeyword || 1));
   const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
@@ -296,6 +297,7 @@ async function collectLiveDataset({ profile, token, options = {} }) {
   const errors = [];
   const keywords = [];
   const effectiveKeywords = [];
+  const completedKeywords = [];
 
   for (const rawKeyword of rawKeywords) {
     const issue = keywordValidationIssue(rawKeyword);
@@ -324,7 +326,31 @@ async function collectLiveDataset({ profile, token, options = {} }) {
     return { videos, comments, warnings, errors, keywords };
   }
 
-  for (const keyword of keywords) {
+  for (let batchIndex = 0; batchIndex < keywords.length; batchIndex++) {
+    const keyword = keywords[batchIndex];
+    if (Array.isArray(options.skipKeywords) && options.skipKeywords.includes(keyword)) continue;
+    if (typeof options.beforeBatch === 'function') {
+      const decision = await options.beforeBatch({
+        platform: 'douyin',
+        keyword,
+        batchIndex,
+        completedKeywords: completedKeywords.slice()
+      });
+      if (decision && decision.allowed === false) {
+        errors.push({
+          stage: 'budget_guard',
+          kind: 'budget',
+          keyword,
+          batchIndex,
+          budget: decision,
+          message: decision.status === 'needs_recharge'
+            ? '当前余额不足,已在下一批调用前停止。'
+            : '已达到本地预算上限,已在下一批调用前停止。',
+          httpStatus: decision.status === 'needs_recharge' ? 402 : 0
+        });
+        break;
+      }
+    }
     try {
       const result = await searchTopVideosWithRepair({
         api,
@@ -355,12 +381,14 @@ async function collectLiveDataset({ profile, token, options = {} }) {
               keyword,
               videoId: video.id,
               message: error.message,
-              httpStatus: error.httpStatus
+              httpStatus: error.httpStatus,
+              kind: error.kind || error.errorKind || ''
             });
             break;
           }
         }
       }
+      completedKeywords.push(keyword);
     } catch (error) {
       errors.push({
         stage: 'search_videos',
@@ -377,7 +405,8 @@ async function collectLiveDataset({ profile, token, options = {} }) {
     comments,
     warnings,
     errors,
-    keywords: effectiveKeywords.length ? effectiveKeywords : keywords
+    keywords: effectiveKeywords.length ? effectiveKeywords : keywords,
+    completedKeywords
   };
 }
 

+ 31 - 33
claude-code/claude-code-voc-intelligence/mcp/src/features/fmode-image-analysis/image-analysis.js

@@ -1,7 +1,12 @@
 const fs = require('fs');
 const path = require('path');
-const { readVocToken, readNewApiToken, readEnvFileMaybe, envLocalCandidates } = require('../../core/credentials');
-const { buildVocRechargeInfo } = require('../../core/payment-links');
+const { readCredentialContext, readVocToken, readNewApiToken, readEnvFileMaybe, envLocalCandidates } = require('../../core/credentials');
+const {
+  buildVocRechargeInfo,
+  buildMissingTokenMessage,
+  buildRechargeRequiredMessage,
+  buildPermissionDeniedMessage
+} = require('../../core/payment-links');
 const { okResult, errorResult } = require('../../core/result-envelope');
 
 const DEFAULT_BASE_URL = 'https://api.fmode.cn';
@@ -26,6 +31,7 @@ async function analyzeFmodeImage(input = {}) {
   }
 
   const provider = resolveProvider(input);
+  const credentials = readCredentialContext(input);
   const token = resolveFmodeToken(input);
   if (!token) {
     const recharge = await buildVocRechargeInfo();
@@ -67,12 +73,29 @@ async function analyzeFmodeImage(input = {}) {
     });
   }
 
-  if (response.status === 402 || response.status === 403) {
-    const recharge = await buildVocRechargeInfo({ token });
-    return friendlyResult('needs_recharge', buildRechargeMessage(recharge.paymentUrl, recharge), {
-      summary: { imageCount: images.length, model: provider.model },
+  if (response.status === 402) {
+    const recharge = await buildVocRechargeInfo({ sessionToken: credentials.sessionToken });
+    const rechargeStatus = recharge.personalized ? 'needs_recharge' : 'needs_session';
+    return friendlyResult(rechargeStatus, buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '图片分析' }), {
+      summary: {
+        imageCount: images.length,
+        model: provider.model,
+        errorKind: recharge.personalized ? 'billing' : 'missing_session_for_recharge',
+        billingErrorKind: 'billing'
+      },
       data: { recharge, model: provider.model, imageCount: images.length },
-      nextActions: [`打开开通/充值链接:${recharge.paymentUrl}`]
+      recharge,
+      nextActions: recharge.personalized
+        ? [`打开免登录充值链接:${recharge.paymentUrl}`]
+        : ['恢复当前用户 Session Token 后生成个人充值链接或支付二维码']
+    });
+  }
+
+  if (response.status === 403) {
+    return friendlyResult('needs_permission', buildPermissionDeniedMessage({ platformLabel: '图片分析' }), {
+      summary: { imageCount: images.length, model: provider.model, errorKind: 'permission' },
+      data: { model: provider.model, imageCount: images.length },
+      nextActions: ['确认当前账号状态和图片分析模型权限后重试']
     });
   }
 
@@ -372,38 +395,13 @@ function buildAssistantMessage({ analysis, provider, images, files }) {
   ].filter(Boolean).join('\n');
 }
 
-function buildMissingTokenMessage(paymentUrl, recharge = {}) {
-  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
-  return [
-    '还没读到可用于 Fmode 图片分析的计费 token,暂时不能调用 vision 模型——这是**配置/读取**问题,不是余额不足,也不代表这个功能用不了。',
-    '',
-    '这把计费 token 就是 Claude Code 的 `ANTHROPIC_AUTH_TOKEN`(`sk-` 开头),通常配在用户级 `~/.claude/settings.json` 的 `env` 里(`ANTHROPIC_BASE_URL` 指向 `api.fmode.cn`)。',
-    '请优先取出它,用 `FMODE_API_KEY=sk-…`(或入参 `newapiToken`/`fmodeToken`)传给本工具重试;也可写进当前工作区 `.env.local`。',
-    '',
-    `(仅当确认是 402 余额不足时才充值:${paymentUrl},套餐入口:${packageUrl})`,
-    '',
-    '系统只会把 token 用作接口鉴权,不会在聊天、报告、日志或输出文件里回显。'
-  ].join('\n');
-}
-
-function buildRechargeMessage(paymentUrl, recharge = {}) {
-  const packageUrl = recharge.recommendedPackageUrl || recharge.workshopUrl || paymentUrl;
-  return [
-    'Fmode 图片分析服务未开通或额度不足,暂时不能继续调用 Doubao vision 模型。',
-    '',
-    `请打开开通/充值链接补充额度:${paymentUrl}`,
-    `套餐入口:${packageUrl}`,
-    '',
-    '底层鉴权或额度错误详情已隐藏,不会把上游响应、Authorization header 或 token 展示给用户。'
-  ].join('\n');
-}
-
 function friendlyResult(status, assistantMessage, payload = {}) {
   return {
     status,
     assistantMessage,
     summary: payload.summary || {},
     data: payload.data || {},
+    ...(payload.recharge ? { recharge: payload.recharge } : {}),
     files: payload.files || [],
     nextActions: payload.nextActions || [],
     warnings: payload.warnings || [],

+ 42 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-discovery.js

@@ -0,0 +1,42 @@
+'use strict';
+
+const URL_RE = /^https?:\/\/[^\s<>"']+$/i;
+const MEDIA_KEY_RE = /(image|img|video|audio|media|cover|avatar|picture|photo|buyer.?show|detail.?long|素材|图片|视频|音频|买家秀|封面)/i;
+
+function discoverMedia(value, options = {}) {
+  const found = [];
+  const seen = new Set();
+  const maxItems = Math.max(1, Number(options.maxItems) || 1000);
+  function visit(node, key = '', parent = {}) {
+    if (found.length >= maxItems || node == null) return;
+    if (typeof node === 'string') {
+      const text = node.trim();
+      if ((URL_RE.test(text) || /^data:(image|video|audio)\//i.test(text)) && (MEDIA_KEY_RE.test(key) || /\.(?:jpe?g|png|gif|webp|svg|mp4|mov|webm|mp3|wav|m4a)(?:[?#]|$)/i.test(text))) {
+        const dedupe = `${key}|${text}`;
+        if (!seen.has(dedupe)) {
+          seen.add(dedupe);
+          found.push({
+            sourceUrl: text,
+            mediaType: /video|\.mp4|\.mov|\.webm/i.test(key + text) ? 'video' : /audio|\.mp3|\.wav|\.m4a/i.test(key + text) ? 'audio' : 'image',
+            field: key || 'media',
+            entityType: options.entityType || parent.entityType || 'evidence',
+            entityId: String(options.entityId || parent.id || parent.noteId || parent.itemId || parent.productId || '').slice(0, 120)
+          });
+        }
+      }
+      return;
+    }
+    if (typeof node !== 'object') return;
+    if (seen.has(node)) return;
+    seen.add(node);
+    if (Array.isArray(node)) {
+      node.forEach(item => visit(item, key, parent));
+    } else {
+      for (const [childKey, child] of Object.entries(node)) visit(child, childKey, node);
+    }
+  }
+  visit(value);
+  return found;
+}
+
+module.exports = { discoverMedia, discoverMediaUrls: discoverMedia, MEDIA_KEY_RE };

+ 52 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-manifest.js

@@ -0,0 +1,52 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+function manifestPath(outputDir) {
+  return path.join(path.resolve(outputDir), 'assets', 'media-manifest.json');
+}
+
+function createMediaManifest(usageTraceId = null) {
+  return { usageTraceId: usageTraceId || null, generatedAt: new Date().toISOString(), items: [] };
+}
+
+function normalizeLocalOnlyItem(item = {}) {
+  const localPath = typeof item.localPath === 'string' ? item.localPath : '';
+  return {
+    ...item,
+    cloudUrl: null,
+    uploadStatus: 'disabled_local_only',
+    effectiveUrl: localPath
+  };
+}
+
+function loadMediaManifest(outputDir, usageTraceId = null) {
+  const file = manifestPath(outputDir);
+  try {
+    const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
+    if (parsed && Array.isArray(parsed.items)) {
+      return { ...parsed, items: parsed.items.map(normalizeLocalOnlyItem) };
+    }
+  } catch {}
+  return createMediaManifest(usageTraceId);
+}
+
+function upsertMediaItem(manifest, item) {
+  const key = item.mediaId || `${item.sourceUrl || ''}|${item.sha256 || ''}`;
+  const index = manifest.items.findIndex(existing => (existing.mediaId || `${existing.sourceUrl || ''}|${existing.sha256 || ''}`) === key);
+  if (index >= 0) manifest.items[index] = { ...manifest.items[index], ...item };
+  else manifest.items.push(item);
+  return manifest.items[index >= 0 ? index : manifest.items.length - 1];
+}
+
+function saveMediaManifest(outputDir, manifest) {
+  const file = manifestPath(outputDir);
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  manifest.items = Array.isArray(manifest.items) ? manifest.items.map(normalizeLocalOnlyItem) : [];
+  manifest.updatedAt = new Date().toISOString();
+  fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
+  return file;
+}
+
+module.exports = { manifestPath, createMediaManifest, createManifest: createMediaManifest, normalizeLocalOnlyItem, loadMediaManifest, upsertMediaItem, saveMediaManifest, writeManifest: saveMediaManifest };

+ 90 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/media/media-pipeline.js

@@ -0,0 +1,90 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { discoverMedia } = require('./media-discovery');
+const { loadMediaManifest, upsertMediaItem, saveMediaManifest } = require('./media-manifest');
+
+function dataUrl(value) {
+  const match = String(value || '').match(/^data:([^,]*),(.*)$/s);
+  if (!match) return null;
+  const meta = match[1] || 'application/octet-stream';
+  return { mimeType: meta.split(';')[0], buffer: meta.includes('base64') ? Buffer.from(match[2], 'base64') : Buffer.from(decodeURIComponent(match[2])) };
+}
+
+function extension(mime = '', url = '') {
+  const map = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/svg+xml': '.svg', 'video/mp4': '.mp4', 'audio/mpeg': '.mp3', 'audio/wav': '.wav' };
+  if (map[mime.toLowerCase()]) return map[mime.toLowerCase()];
+  const ext = path.extname(String(url).split(/[?#]/)[0]).toLowerCase();
+  return ext && ext.length <= 6 ? ext : '.bin';
+}
+
+async function fetchMedia(sourceUrl, options = {}) {
+  const parsed = dataUrl(sourceUrl);
+  if (parsed) return parsed;
+  const response = await (options.fetchImpl || fetch)(sourceUrl, { signal: options.signal, headers: { Accept: '*/*' } });
+  if (!response.ok) throw new Error(`media request failed with HTTP ${response.status}`);
+  return { mimeType: response.headers.get('content-type') || '', buffer: Buffer.from(await response.arrayBuffer()) };
+}
+
+async function processMedia(value, options = {}) {
+  const outputDir = path.resolve(options.outputDir || path.join(process.cwd(), 'outputs', options.usageTraceId || 'voc-run'));
+  fs.mkdirSync(path.join(outputDir, 'assets'), { recursive: true });
+  const manifest = loadMediaManifest(outputDir, options.usageTraceId);
+  const discovered = discoverMedia(value, options);
+  const files = [];
+  const warnings = [];
+  const replacements = new Map();
+  for (const source of discovered) {
+    // Media is deliberately local-only. The output directory is the durable
+    // handoff for reports and keeps this workflow from consuming cloud storage.
+    const item = {
+      mediaId: crypto.createHash('sha1').update(source.sourceUrl).digest('hex').slice(0, 16),
+      ...source,
+      cacheStatus: 'pending',
+      uploadStatus: 'disabled_local_only',
+      cloudUrl: null
+    };
+    try {
+      const fetched = await fetchMedia(source.sourceUrl, options);
+      if (!fetched.buffer?.length) throw new Error('empty media response');
+      const sha256 = crypto.createHash('sha256').update(fetched.buffer).digest('hex');
+      const ext = extension(fetched.mimeType, source.sourceUrl);
+      const entity = String(source.entityId || 'evidence').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'evidence';
+      const relative = path.posix.join('assets', options.platform || 'voc', entity, `${sha256.slice(0, 20)}${ext}`);
+      const absolute = path.join(outputDir, relative);
+      fs.mkdirSync(path.dirname(absolute), { recursive: true });
+      if (!fs.existsSync(absolute)) fs.writeFileSync(absolute, fetched.buffer);
+      item.localPath = relative;
+      item.localAbsolutePath = absolute;
+      item.mimeType = fetched.mimeType || 'application/octet-stream';
+      item.bytes = fetched.buffer.length;
+      item.sha256 = sha256;
+      item.cacheStatus = 'cached';
+      files.push(absolute);
+      item.effectiveUrl = item.localPath;
+      replacements.set(source.sourceUrl, item.effectiveUrl);
+    } catch (error) {
+      item.cacheStatus = 'failed';
+      item.failure = String(error.message || error);
+      item.effectiveUrl = '';
+      // Do not let a failed upstream URL leak into report output as a hot link.
+      replacements.set(source.sourceUrl, '');
+      warnings.push({ stage: 'media_cache', sourceUrl: source.sourceUrl, message: item.failure });
+    }
+    upsertMediaItem(manifest, item);
+  }
+  const manifestFile = saveMediaManifest(outputDir, manifest);
+  return { manifest, manifestFile, items: manifest.items, files, warnings, replacements };
+}
+
+function replaceMediaUrls(value, replacements, key = '') {
+  if (!replacements || !replacements.size) return value;
+  if (typeof value === 'string') return replacements.has(value) && !/sourceUrl|originalUrl|coverOriginalUrl/i.test(key) ? replacements.get(value) : value;
+  if (Array.isArray(value)) return value.map(item => replaceMediaUrls(item, replacements, key));
+  if (!value || typeof value !== 'object') return value;
+  return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, replaceMediaUrls(child, replacements, childKey)]));
+}
+
+module.exports = { processMedia, runMediaPipeline: processMedia, replaceMediaUrls, fetchMedia, extension };

+ 90 - 3
claude-code/claude-code-voc-intelligence/mcp/src/features/voc-business-workflow/business-workflow.js

@@ -5,6 +5,9 @@ const { runVocIssuePool } = require('../../tools/voc-issue-pool-run');
 const { runVocProblemDeepDive } = require('../../tools/voc-problem-deep-dive-run');
 const { runVocContentPlan } = require('../../tools/voc-content-plan-run');
 const { runVocSpeakingScript } = require('../../tools/voc-speaking-script-run');
+const { runVocCostEstimate } = require('../../tools/voc-cost-estimate-run');
+const { runVocUsageReport } = require('../../tools/voc-usage-report-run');
+const { contextForTool, writeUsageContext } = require('../../core/usage-context');
 
 function asList(value) {
   if (!value) return [];
@@ -66,6 +69,52 @@ async function runBusinessWorkflow(input = {}) {
   const audience = firstText(input.audience, input.targetAudience, '目标用户');
   const keywords = asList(input.keywords).length ? asList(input.keywords) : asList(input.keyword);
   const outputRoot = path.resolve(input.output || path.join('outputs', 'voc-business-workflow', new Date().toISOString().slice(0, 10)));
+  const usageContext = contextForTool({
+    ...input,
+    skillId: input.skillId || 'voc-business-workflow',
+    workflowType: input.workflowType || 'voc_business_workflow',
+    title: input.title || `${brand || project} VOC经营闭环`
+  });
+
+  // Complex/live runs may perform many gateway calls. When the caller supplies
+  // a budget or explicitly requests a preflight, let the Agent price the same
+  // business scope before the first collection batch.
+  let estimateResult = input.estimate && typeof input.estimate === 'object' ? input.estimate : null;
+  const accountCount = Number(input.accountCount || (Array.isArray(input.accounts) ? input.accounts.length : 0));
+  const productCount = Number(input.productCount || input.skuCount || 0);
+  const keywordCount = keywords.length;
+  const shouldEstimate = input.preflightEstimate === true || input.preflightEstimate === 'true' ||
+    input.budgetCny != null || input.budgetLimitCny != null ||
+    (collectionMode === 'live' && (accountCount > 20 || productCount > 20 || keywords.length > 20));
+  if (shouldEstimate && !estimateResult && (accountCount > 0 || productCount > 0 || keywordCount > 0 || Array.isArray(input.operations))) {
+    try {
+      estimateResult = await runVocCostEstimate({
+        ...input,
+        workflowType: input.costWorkflowType || (accountCount > 0 ? 'xhs_account_monitor' : productCount > 0 ? 'tmall_listing_audit' : `${platform}_trend_report`),
+        keywordCount: keywordCount || undefined,
+        accountCount: accountCount || undefined,
+        productCount: productCount || undefined,
+        usageTraceId: usageContext.usageTraceId,
+        reportId: usageContext.reportId,
+        skillId: usageContext.skillId,
+        outputDir: outputRoot,
+      });
+      if (estimateResult.status !== 'ok' && input.requireEstimate === true) return estimateResult;
+    } catch (error) {
+      if (input.requireEstimate === true) {
+        return {
+          status: 'error',
+          assistantMessage: `执行前成本测算失败:${error.message || String(error)}`,
+          summary: { usageTraceId: usageContext.usageTraceId, errorKind: 'estimate_failed' },
+          data: { context: usageContext },
+          files: [],
+          nextActions: ['检查当前 NewAPI 凭证和计费规则后重试'],
+          warnings: [],
+          errors: [{ message: error.message || String(error) }]
+        };
+      }
+    }
+  }
 
   const trendInput = {
     ...input,
@@ -74,6 +123,12 @@ async function runBusinessWorkflow(input = {}) {
     targetAudience: Array.isArray(input.targetAudience) ? input.targetAudience : [audience],
     keywords: keywords.length ? keywords : undefined,
     collectionMode,
+    usageTraceId: usageContext.usageTraceId,
+    reportId: usageContext.reportId,
+    skillId: usageContext.skillId,
+    workflowType: usageContext.workflowType,
+    clientId: usageContext.clientId,
+    budgetCny: usageContext.budgetLimitCny,
     output: path.join(outputRoot, platform === 'xiaohongshu' ? 'xiaohongshu-trend' : 'douyin-trend'),
     keywordLimit: input.keywordLimit || input['keyword-limit'] || 2,
     notesPerKeyword: input.notesPerKeyword || input['notes-per-keyword'] || 2,
@@ -87,6 +142,10 @@ async function runBusinessWorkflow(input = {}) {
   if (trendResult.status !== 'ok') {
     return {
       ...trendResult,
+      usage: trendResult.usage || trendResult.data?.usage || usageContext,
+      estimate: trendResult.estimate || estimateResult || null,
+      recharge: trendResult.recharge || estimateResult?.recharge || null,
+      checkpoint: trendResult.checkpoint || (trendResult.files || []).find(file => /checkpoint\.json$/i.test(String(file))) || null,
       assistantMessage: [
         '# VOC经营闭环未完成',
         '',
@@ -100,6 +159,21 @@ async function runBusinessWorkflow(input = {}) {
   }
 
   const evidence = collectEvidence(trendResult);
+  // Live collection already carries this trace; aggregate actual NewAPI
+  // usage before local report-generation steps.
+  let usageReport = null;
+  if (collectionMode === 'live' && usageContext.usageTraceId) {
+    try {
+      usageReport = await runVocUsageReport({
+        ...input,
+        usageTraceId: usageContext.usageTraceId,
+        reportId: usageContext.reportId,
+        refresh: true
+      });
+    } catch (error) {
+      usageReport = { status: 'upstream_unstable', warnings: [`用量汇总暂时不可用:${error.message || String(error)}`] };
+    }
+  }
   const issuePool = await runVocIssuePool({
     project,
     brand,
@@ -176,6 +250,11 @@ async function runBusinessWorkflow(input = {}) {
     '3. 7 天后反馈哪些动作有效/无效,让记忆继续优化。'
   ].join('\n');
 
+  const usageContextFile = writeUsageContext(usageContext, outputRoot);
+  if (usageContextFile) files.push(usageContextFile);
+  const checkpoint = (trendResult.files || []).find(file => /checkpoint\.json$/i.test(String(file))) || null;
+  const actualUsage = trendResult.usage || trendResult.data?.usage || usageContext;
+  const usageData = usageReport?.status === 'ok' ? usageReport.data : null;
   return {
     status: 'ok',
     assistantMessage,
@@ -187,23 +266,31 @@ async function runBusinessWorkflow(input = {}) {
       issueCount: issues.length,
       topIssue,
       contentTopicCount: contentPlan.summary && contentPlan.summary.topicCount,
-      speakingScriptStatus: speakingScript.status
+      speakingScriptStatus: speakingScript.status,
+      dataCollectionCny: usageData?.totals?.costCny ?? null,
+      dataCollectionQuota: usageData?.totals?.quota ?? null
     },
     data: {
       trend: trendResult,
       issuePool,
       deepDive,
       contentPlan,
-      speakingScript
+      speakingScript,
+      usageReport
     },
     files,
+    usage: usageData || actualUsage,
+    estimate: estimateResult || null,
+    recharge: trendResult.recharge || null,
+    checkpoint,
     nextActions: [
       `确认 Top 问题“${topIssue}”是否优先`,
       '选择 7 天内容计划中的一条继续改成最终口播稿',
       '执行 7 天后反馈有效/无效动作,让记忆继续收敛'
     ],
     warnings: [
-      collectionMode === 'sample' ? '本轮使用 sample 演示数据,不是真实采集。' : ''
+      collectionMode === 'sample' ? '本轮使用 sample 演示数据,不是真实采集。' : '',
+      ...(usageReport?.warnings || [])
     ].filter(Boolean),
     errors: []
   };

+ 76 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/voc-cost/estimate-report.js

@@ -0,0 +1,76 @@
+'use strict';
+
+const { buildEstimateBands, calculateCostFromOperations, checkBudget, round } = require('../../core/budget-guard');
+
+function valueRecommendations(operations = [], totalCny = 0) {
+  return operations
+    .map((op) => {
+      const cost = round(op.amountCny ?? Number(op.priceCnyPerCall || 0) * Number(op.count || 0));
+      const share = totalCny > 0 ? cost / totalCny : 0;
+      const valueScore = Number.isFinite(Number(op.valueScore)) ? Number(op.valueScore) : 3;
+      return { ...op, costCny: cost, costShare: round(share * 100, 1), valueScore,
+        wasteCandidate: op.required === false && share >= 0.1 && valueScore <= 2 };
+    })
+    .sort((a, b) => b.costCny - a.costCny);
+}
+
+function buildPlanEstimate(plan, pricedOperations = [], options = {}) {
+  const operations = pricedOperations.length ? pricedOperations : (plan.operations || []);
+  const totals = calculateCostFromOperations(operations);
+  const bands = buildEstimateBands(totals, {
+    lowFactor: options.lowFactor ?? (plan.mode === 'economy' ? 0.9 : 0.8),
+    highFactor: options.highFactor ?? (plan.mode === 'complete' ? 1.4 : 1.25)
+  });
+  const breakdown = valueRecommendations(operations, totals.amountCny);
+  const optimization = breakdown.filter(item => item.wasteCandidate).map(item => ({
+    endpoint: item.path,
+    suggestion: '降低采样频次或按需调用',
+    estimatedSavingCny: item.costCny,
+    impact: '该步骤不是必需字段,删除后对应维度覆盖会下降。'
+  }));
+  const budget = options.budgetLimitCny == null ? null : checkBudget({
+    estimate: totals,
+    bands,
+    balance: options.balance,
+    budgetLimitCny: options.budgetLimitCny,
+    actualCostCny: options.actualCostCny,
+    nextCostCny: bands.highCny,
+    rechargeBufferCny: options.rechargeBufferCny
+  });
+  return {
+    id: plan.id,
+    mode: plan.mode,
+    label: plan.label,
+    strategy: plan.strategy,
+    assumptions: plan.assumptions,
+    calls: totals.calls,
+    quota: totals.quota,
+    amountCny: round(totals.amountCny, 6),
+    lowCny: bands.lowCny,
+    expectedCny: bands.expectedCny,
+    highCny: bands.highCny,
+    estimateLowCny: bands.lowCny,
+    estimateExpectedCny: bands.expectedCny,
+    estimateHighCny: bands.highCny,
+    lowQuota: bands.lowQuota,
+    expectedQuota: bands.expectedQuota,
+    highQuota: bands.highQuota,
+    operations: breakdown,
+    optimization,
+    ...(budget ? { budget } : {})
+  };
+}
+
+function formatEstimateMessage(report, balance = {}) {
+  const lines = ['成本预估(用户 NewAPI 实际扣费口径)'];
+  for (const plan of report.plans || []) {
+    lines.push(`${plan.label}:预计 ¥${plan.expectedCny.toFixed(2)},区间 ¥${plan.lowCny.toFixed(2)} - ¥${plan.highCny.toFixed(2)},约 ${plan.calls} 次调用。`);
+    if (plan.budget?.status === 'warning') lines.push(`  已接近预算上限,预计占用预算 ${Math.round((plan.budget.projectedCny / plan.budget.budgetLimitCny) * 100)}%。`);
+    if (plan.budget?.status === 'blocked') lines.push('  已超过本地预算上限,建议缩小范围后再执行。');
+  }
+  if (balance.availableQuota !== undefined) lines.push(`当前余额:${balance.availableQuota} quota。`);
+  if (report.shortestPlan) lines.push(`可优先选择${report.shortestPlan.label}控制成本。`);
+  return lines.join('\n');
+}
+
+module.exports = { valueRecommendations, buildPlanEstimate, formatEstimateMessage };

+ 144 - 0
claude-code/claude-code-voc-intelligence/mcp/src/features/voc-cost/workflow-planner.js

@@ -0,0 +1,144 @@
+'use strict';
+
+const { cleanLabel } = require('../../core/usage-context');
+
+const MODES = ['economy', 'standard', 'complete'];
+
+function positive(value, fallback = 0) {
+  const n = Number(value);
+  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
+}
+
+function modeLabel(mode) {
+  return ({ economy: '经济版', standard: '标准版', complete: '完整版' })[mode] || mode;
+}
+
+function operation(model, path, count, label, metadata = {}) {
+  if (!count) return null;
+  return {
+    model,
+    path,
+    count: Math.max(1, positive(count, 1)),
+    label: cleanLabel(label, path),
+    ...metadata
+  };
+}
+
+function planXhs(input, mode) {
+  const accountCount = positive(input.accountCount ?? input.accounts, 1);
+  const unresolved = positive(input.unresolvedAccountCount, 0);
+  const notes = positive(input.notesPerAccount, mode === 'complete' ? 2 : 1) || 1;
+  const detailNotes = positive(input.detailNotesPerAccount ?? input.notesForDetail,
+    mode === 'economy' ? 0 : mode === 'standard' ? Math.min(2, notes) : notes);
+  const comments = positive(input.commentPagesPerNote, mode === 'complete' ? 1 : 0);
+  const operations = [
+    operation('voc-social', 'xiaohongshu.search_user_v2', unresolved, '账号搜索匹配', { valueScore: 3, required: unresolved > 0 }),
+    operation('voc-social', 'xiaohongshu.get_user_note_list_v4', accountCount * notes, '账号笔记列表', { valueScore: 5, required: true }),
+    operation('voc-social', input.detailEndpoint || 'xiaohongshu.get_note_detail_v4', accountCount * detailNotes, '入选笔记详情', { valueScore: mode === 'economy' ? 1 : 4, required: mode !== 'economy' }),
+    operation('voc-social', input.commentEndpoint || 'xiaohongshu.get_note_comment_v3', accountCount * detailNotes * comments, '入选笔记评论', { valueScore: mode === 'complete' ? 4 : 2, required: mode === 'complete' })
+  ].filter(Boolean);
+  return {
+    id: mode,
+    mode,
+    label: modeLabel(mode),
+    workflowType: 'xhs_account_monitor',
+    assumptions: { accountCount, unresolvedAccountCount: unresolved, notesPerAccount: notes, detailNotesPerAccount: detailNotes, commentPagesPerNote: comments },
+    operations,
+    strategy: mode === 'economy' ? '只解析未匹配账号,读取一页笔记,不采集逐条评论。' : mode === 'standard' ? '全量读取一页笔记,仅对入选笔记采集详情,评论按需抽样。' : '多页笔记、详情和评论完整覆盖,并补充用户画像。'
+  };
+}
+
+function planTmall(input, mode) {
+  const productCount = positive(input.productCount ?? input.skuCount ?? input.itemCount, 134);
+  const listPages = positive(input.shopListPages ?? input.listPages, Math.max(1, Math.ceil(productCount / 30)));
+  const representative = positive(input.representativeSkuCount ?? input.sampleSkuCount, 8);
+  const deepCount = mode === 'economy' ? representative : mode === 'standard' ? Math.min(20, productCount) : productCount;
+  const comments = positive(input.commentPagesPerItem ?? input.commentPagesPerSku ?? input.commentPages, mode === 'complete' ? 1 : 0);
+  const operations = [
+    operation('voc-ecom', input.shopListEndpoint || 'taobao.get_shop_item_list_v4', listPages, '店铺商品清单', { valueScore: 5, required: true }),
+    operation('voc-ecom', input.detailEndpoint || 'taobao.get_item_detail_v4', deepCount, mode === 'economy' ? '代表商品价格/SKU/主图' : '商品轻量价格/SKU/主图', { valueScore: 5, required: true }),
+    operation('voc-ecom', input.snapshotEndpoint || 'taobao.get_item_detail_v3', deepCount, '商品销量/详情素材/属性', { valueScore: mode === 'economy' ? 3 : 5, required: mode !== 'economy' }),
+    operation('voc-ecom', input.commentEndpoint || 'taobao.get_item_comment_v3', deepCount * comments, '商品评价', { valueScore: 4, required: mode === 'complete' })
+  ].filter(Boolean);
+  return {
+    id: mode,
+    mode,
+    label: modeLabel(mode),
+    workflowType: 'tmall_listing_audit',
+    assumptions: { productCount, shopListPages: listPages, representativeSkuCount: representative, deepItemCount: deepCount, commentPagesPerItem: comments },
+    operations,
+    strategy: mode === 'economy' ? `${listPages} 页商品清单,按销量/新品/价格抽取 ${representative} 个代表商品。` : mode === 'standard' ? `全店 ${productCount} 个商品做轻量覆盖,Top ${deepCount} 个做完整诊断。` : `${productCount} 个商品全部采集价格、素材、属性和评价并逐项打分。`
+  };
+}
+
+function planSocialTrend(input, mode, platform) {
+  const keywordCount = positive(input.keywordCount ?? (Array.isArray(input.keywords) ? input.keywords.length : input.keywords), 1);
+  const itemCount = positive(
+    input.itemsPerKeyword ?? input.notesPerKeyword ?? input.videosPerKeyword,
+    mode === 'complete' ? 3 : mode === 'standard' ? 2 : 1
+  );
+  const commentPages = positive(input.commentPagesPerKeyword ?? input.maxCommentPages ?? input.commentPagesPerNote, mode === 'complete' ? 1 : 0);
+  const commentCount = keywordCount * itemCount * commentPages;
+  const isXhs = platform === 'xiaohongshu';
+  const searchPath = isXhs ? 'search_notes' : 'search/fetch_general_search_v2';
+  const itemPath = isXhs ? 'get_note_info' : 'app/v3/fetch_video_comments';
+  const commentPath = isXhs ? 'get_note_comments' : 'app/v3/fetch_video_comments';
+  const operations = [
+    operation('voc-social', searchPath, keywordCount, isXhs ? '小红书关键词搜索' : '抖音关键词搜索', { valueScore: 5, required: true }),
+    isXhs
+      ? operation('voc-social', itemPath, keywordCount * itemCount, '入选笔记详情', { valueScore: mode === 'economy' ? 2 : 4, required: mode !== 'economy' })
+      : null,
+    operation('voc-social', commentPath, commentCount, isXhs ? '入选笔记评论' : '入选视频评论', { valueScore: mode === 'complete' ? 4 : 2, required: mode === 'complete' })
+  ].filter(Boolean);
+  return {
+    id: mode,
+    mode,
+    label: modeLabel(mode),
+    workflowType: `${isXhs ? 'xhs' : 'douyin'}_trend_report`,
+    assumptions: { keywordCount, itemsPerKeyword: itemCount, commentPages },
+    operations,
+    strategy: mode === 'economy'
+      ? `搜索 ${keywordCount} 个关键词,仅保留每词 ${itemCount} 个高相关样本。`
+      : mode === 'standard'
+        ? `搜索 ${keywordCount} 个关键词,每词抽取 ${itemCount} 个样本并按需补充评论。`
+        : `搜索 ${keywordCount} 个关键词,每词覆盖 ${itemCount} 个样本、详情和评论。`
+  };
+}
+
+function planCustom(input, mode) {
+  const source = Array.isArray(input.operations) ? input.operations : [];
+  const operations = source.map((item, index) => ({
+    model: cleanLabel(item.model, 'voc-social'),
+    apigId: cleanLabel(item.apigId),
+    path: cleanLabel(item.path || item.endpointId, `operation_${index + 1}`),
+    count: Math.max(1, positive(item.count, 1)),
+    label: cleanLabel(item.label, `调用步骤 ${index + 1}`),
+    valueScore: Number.isFinite(Number(item.valueScore)) ? Number(item.valueScore) : 3,
+    required: item.required !== false
+  }));
+  return { id: mode, mode, label: modeLabel(mode), workflowType: cleanLabel(input.workflowType, 'voc_custom_workflow'), assumptions: {}, operations, strategy: '使用 Agent 提供的接口调用计划。' };
+}
+
+function buildWorkflowPlans(input = {}) {
+  const type = String(input.workflowType || '').toLowerCase();
+  const xhs = type.includes('xhs') || type.includes('xiaohongshu');
+  const douyin = type.includes('douyin');
+  const accountWorkflow = type.includes('account_monitor') || type.includes('account-monitor');
+  const builder = xhs && accountWorkflow ? planXhs
+    : xhs ? ((input.accountCount ?? input.accounts) != null ? planXhs : (value, mode) => planSocialTrend(value, mode, 'xiaohongshu'))
+      : douyin ? ((value, mode) => planSocialTrend(value, mode, 'douyin'))
+        : type.includes('tmall') || type.includes('listing') || type.includes('taobao') ? planTmall
+          : planCustom;
+  const modes = input.mode && input.mode !== 'all' ? [String(input.mode).toLowerCase()] : MODES;
+  const selected = modes.map(mode => mode === 'full' ? 'complete' : mode === 'low' ? 'economy' : mode).filter(mode => MODES.includes(mode));
+  const effectiveModes = selected.length ? selected : ['standard'];
+  return effectiveModes.map(mode => builder(input, mode));
+}
+
+function flattenPlans(plans = []) {
+  const items = [];
+  for (const plan of plans) for (const op of plan.operations || []) items.push({ ...op, _planId: plan.id });
+  return items;
+}
+
+module.exports = { MODES, modeLabel, buildWorkflowPlans, flattenPlans, planXhs, planTmall, planSocialTrend, planCustom };

+ 37 - 7
claude-code/claude-code-voc-intelligence/mcp/src/features/xiaohongshu-trend/live-collector.js

@@ -249,8 +249,8 @@ function extractComments(data) {
   return Array.isArray(comments) ? comments : [];
 }
 
-async function collectLiveDataset({ profile, token, options = {} }) {
-  const api = new XiaohongshuApi({ token, baseUrl: options.baseUrl });
+async function collectLiveDataset({ profile, token, fallbackToken, options = {} }) {
+  const api = new XiaohongshuApi({ token, fallbackToken, baseUrl: options.baseUrl, headers: options.headers });
   const keywordLimit = Math.max(1, Number(options.keywordLimit || 1));
   const notesPerKeyword = Math.max(1, Number(options.notesPerKeyword || 1));
   const maxCommentPages = Math.max(0, Number(options.maxCommentPages ?? 1));
@@ -259,8 +259,33 @@ async function collectLiveDataset({ profile, token, options = {} }) {
   const comments = [];
   const warnings = [];
   const errors = [];
+  const completedKeywords = [];
 
-  for (const keyword of keywords) {
+  for (let batchIndex = 0; batchIndex < keywords.length; batchIndex++) {
+    const keyword = keywords[batchIndex];
+    if (Array.isArray(options.skipKeywords) && options.skipKeywords.includes(keyword)) continue;
+    if (typeof options.beforeBatch === 'function') {
+      const decision = await options.beforeBatch({
+        platform: 'xiaohongshu',
+        keyword,
+        batchIndex,
+        completedKeywords: completedKeywords.slice()
+      });
+      if (decision && decision.allowed === false) {
+        errors.push({
+          stage: 'budget_guard',
+          kind: 'budget',
+          keyword,
+          batchIndex,
+          budget: decision,
+          message: decision.status === 'needs_recharge'
+            ? '当前余额不足,已在下一批调用前停止。'
+            : '已达到本地预算上限,已在下一批调用前停止。',
+          httpStatus: decision.status === 'needs_recharge' ? 402 : 0
+        });
+        break;
+      }
+    }
     try {
       const search = await api.searchNotes({
         keyword,
@@ -284,7 +309,8 @@ async function collectLiveDataset({ profile, token, options = {} }) {
             keyword,
             noteId: rawNote.id,
             message: error.message,
-            httpStatus: error.httpStatus
+            httpStatus: error.httpStatus,
+            kind: error.kind
           });
         }
         notes.push(note);
@@ -304,18 +330,21 @@ async function collectLiveDataset({ profile, token, options = {} }) {
               keyword,
               noteId: note.id,
               message: error.message,
-              httpStatus: error.httpStatus
+              httpStatus: error.httpStatus,
+              kind: error.kind
             });
             break;
           }
         }
       }
+      completedKeywords.push(keyword);
     } catch (error) {
       errors.push({
         stage: 'search_notes',
         keyword,
         message: error.message,
-        httpStatus: error.httpStatus
+        httpStatus: error.httpStatus,
+        kind: error.kind
       });
     }
   }
@@ -324,7 +353,8 @@ async function collectLiveDataset({ profile, token, options = {} }) {
     notes,
     comments,
     warnings,
-    errors
+    errors,
+    completedKeywords
   };
 }
 

+ 27 - 150
claude-code/claude-code-voc-intelligence/mcp/src/providers/douyin-api.js

@@ -1,150 +1,33 @@
+const { callSocialGateway } = require('./voc-gateway');
+
 const DOUYIN_API_ROOT = 'https://server.fmode.cn/api/voc-social/douyin';
 const DOUYIN_APP_BASE_URL = `${DOUYIN_API_ROOT}/app`;
 const DOUYIN_SEARCH_PATH = 'search/fetch_general_search_v2';
 const DOUYIN_VIDEO_COMMENTS_PATH = 'app/v3/fetch_video_comments';
 const DOUYIN_VIDEO_REPLIES_PATH = 'app/v3/fetch_video_comment_replies';
 
-function buildEndpointUrl(baseUrl, endpointPath) {
-  return `${String(baseUrl || '').replace(/\/$/, '')}/${String(endpointPath || '').replace(/^\//, '')}`;
-}
-
-function buildUrl(baseUrl, path, query = {}) {
-  const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
-  Object.entries(query).forEach(([key, value]) => {
-    if (value !== undefined && value !== null && value !== '') {
-      url.searchParams.set(key, String(value));
-    }
-  });
-  return url.toString();
-}
-
-function unwrapData(json) {
-  if (!json || typeof json !== 'object') return {};
-  return json.data?.data || json.data || json;
-}
-
-function pickBusinessStatus(json) {
-  const topCode = Number(json?.code);
-  if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
-    return topCode;
-  }
-  const nestedCode = Number(json?.data?.status_code);
-  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
-    return nestedCode;
-  }
-  return 0;
-}
-
-function redactSecret(value) {
-  return String(value || '')
-    .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
-    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
-    .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
-    .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
-    .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
-}
-
-function isSuccessCode(json) {
-  const topCode = Number(json?.code);
-  if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
-    return false;
-  }
-  const statusCode = Number(json?.data?.status_code);
-  if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
-    return false;
-  }
-  return true;
-}
-
-function classifyApiError({ response, json, message }) {
-  const httpStatus = response?.status || 0;
-  const businessStatus = pickBusinessStatus(json);
-  const status = businessStatus || httpStatus;
-  const text = String(message || '');
-  if (/company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|请输入.*token|sessiontoken/i.test(text)) {
-    return 'auth';
-  }
-  if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
-    return 'auth';
-  }
-  if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
-    return 'billing';
-  }
-  if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
-    return 'billing';
-  }
-  if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
-    return 'request';
-  }
-  if (status >= 500 && /社交平台接口请求错误|request error|接口请求错误/i.test(text)) {
-    return 'upstream_search';
-  }
-  if (status >= 500) {
-    return 'upstream_search';
-  }
-  if (status === 403 || /permission|权限|无权限/i.test(text)) {
-    return 'permission';
-  }
-  return 'upstream';
-}
-
-const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
-
-async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
-  let lastError;
-  for (let attempt = 0; attempt <= retries; attempt++) {
-    let response;
-    let text;
-    try {
-      response = await fetch(url, {
-        method,
-        headers: {
-          Authorization: `Bearer ${token}`,
-          Accept: 'application/json',
-          ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
-        },
-        body: body ? JSON.stringify(body) : undefined
-      });
-      text = await response.text();
-    } catch (networkError) {
-      lastError = networkError;
-      if (attempt < retries) {
-        await delay(400 * (attempt + 1));
-        continue;
-      }
-      throw networkError;
-    }
-    let json;
-    try {
-      json = JSON.parse(text);
-    } catch {
-      json = undefined;
-    }
-    if (!response.ok || (json && !isSuccessCode(json))) {
-      const message = json?.message || json?.msg || json?.data?.msg || json?.data?.message || text || `HTTP ${response.status}`;
-      const error = new Error(redactSecret(message));
-      error.httpStatus = pickBusinessStatus(json) || response.status || 500;
-      error.kind = classifyApiError({ response, json, message });
-      error.responseBody = redactSecret(text);
-      if (error.httpStatus >= 500 && attempt < retries) {
-        lastError = error;
-        await delay(400 * (attempt + 1));
-        continue;
-      }
-      throw error;
-    }
-    return { json, data: unwrapData(json), rawText: text };
-  }
-  throw lastError;
-}
-
 class DouyinApi {
   constructor(options = {}) {
     this.baseUrl = options.baseUrl || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL || DOUYIN_API_ROOT;
     this.token = options.token;
+    this.fallbackToken = options.fallbackToken || '';
+    this.headers = options.headers || {};
   }
 
-  async searchVideos({
+  request(proxyPath, { method = 'GET', query = {}, body } = {}) {
+    return callSocialGateway({
+      proxyPath,
+      method,
+      query,
+      body,
+      token: this.token,
+      fallbackToken: this.fallbackToken,
+      baseUrl: this.baseUrl,
+      headers: this.headers
+    });
+  }
+
+  searchVideos({
     keyword,
     cursor = 0,
     sortType = '0',
@@ -154,8 +37,7 @@ class DouyinApi {
     searchId = '',
     backtrace = ''
   }) {
-    const url = buildEndpointUrl(this.baseUrl, DOUYIN_SEARCH_PATH);
-    return requestJson(url, this.token, {
+    return this.request(DOUYIN_SEARCH_PATH, {
       method: 'POST',
       body: {
         keyword,
@@ -170,21 +52,16 @@ class DouyinApi {
     });
   }
 
-  async getVideoComments({ awemeId, cursor = 0, count = 20 }) {
-    const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_COMMENTS_PATH));
-    url.searchParams.set('aweme_id', String(awemeId));
-    url.searchParams.set('cursor', String(cursor));
-    url.searchParams.set('count', String(count));
-    return requestJson(url, this.token);
+  getVideoComments({ awemeId, cursor = 0, count = 20 }) {
+    return this.request(DOUYIN_VIDEO_COMMENTS_PATH, {
+      query: { aweme_id: awemeId, cursor, count }
+    });
   }
 
-  async getCommentReplies({ itemId, commentId, cursor = 0, count = 20 }) {
-    const url = new URL(buildEndpointUrl(this.baseUrl, DOUYIN_VIDEO_REPLIES_PATH));
-    url.searchParams.set('item_id', String(itemId));
-    url.searchParams.set('comment_id', String(commentId));
-    url.searchParams.set('cursor', String(cursor));
-    url.searchParams.set('count', String(count));
-    return requestJson(url, this.token);
+  getCommentReplies({ itemId, commentId, cursor = 0, count = 20 }) {
+    return this.request(DOUYIN_VIDEO_REPLIES_PATH, {
+      query: { item_id: itemId, comment_id: commentId, cursor, count }
+    });
   }
 }
 

+ 8 - 4
claude-code/claude-code-voc-intelligence/mcp/src/providers/ecommerce-gateway.js

@@ -5,6 +5,7 @@
 const {
   buildGatewayUrl,
   requestJson,
+  authModeForToken,
   classifyApiError,
   redactSecret,
   unwrapData
@@ -23,21 +24,24 @@ function resolveEcommerceRoot(baseUrl) {
 
 // 与 callSocialGateway 完全一致的「优先 newapi sk-、鉴权失败回退 sessionToken」逻辑,
 // 只是走 voc-e-commerce 根地址。402(余额不足)/403(无权限)不回退。
-async function callEcommerceGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3 } = {}) {
+async function callEcommerceGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3, headers = {} } = {}) {
   const root = resolveEcommerceRoot(baseUrl);
   const url = buildGatewayUrl(root, proxyPath, query);
   const upper = String(method || 'GET').toUpperCase();
   const requestOptions = {
     method: upper,
     body: upper === 'GET' ? undefined : body,
-    retries
+    retries,
+    headers
   };
   try {
-    return await requestJson(url, token, requestOptions);
+    const result = await requestJson(url, token, requestOptions);
+    return { ...result, authMode: authModeForToken(token), fallbackUsed: false };
   } catch (error) {
     const canFallback = fallbackToken && fallbackToken !== token && error && error.kind === 'auth';
     if (!canFallback) throw error;
-    return requestJson(url, fallbackToken, requestOptions);
+    const result = await requestJson(url, fallbackToken, requestOptions);
+    return { ...result, authMode: authModeForToken(fallbackToken), fallbackUsed: true };
   }
 }
 

+ 7 - 4
claude-code/claude-code-voc-intelligence/mcp/src/providers/overseas-gateway.js

@@ -5,6 +5,7 @@
 // 任何上游数据供应商名称。
 const {
   requestJson,
+  authModeForToken,
   classifyApiError,
   redactSecret,
   unwrapData
@@ -25,7 +26,7 @@ function resolveOverseasRoot(baseUrl) {
 // query 会被服务端透传为上游 querystring(如 domain),body 透传为上游 JSON body。
 // 与 callSocialGateway 完全一致的「优先 newapi sk-、鉴权失败回退 sessionToken」逻辑,
 // 402(余额不足)/403(无权限)不回退。
-async function callOverseasGateway({ proxyPath, method = 'POST', query = {}, body, token, fallbackToken, baseUrl, retries = 3 } = {}) {
+async function callOverseasGateway({ proxyPath, method = 'POST', query = {}, body, token, fallbackToken, baseUrl, retries = 3, headers = {} } = {}) {
   const root = resolveOverseasRoot(baseUrl);
   const url = `${root}/forward`;
   const rawPath = String(proxyPath || '').trim();
@@ -35,13 +36,15 @@ async function callOverseasGateway({ proxyPath, method = 'POST', query = {}, bod
     query: query || {},
     body: body || {}
   };
-  const requestOptions = { method: 'POST', body: envelope, retries };
+  const requestOptions = { method: 'POST', body: envelope, retries, headers };
   try {
-    return await requestJson(url, token, requestOptions);
+    const result = await requestJson(url, token, requestOptions);
+    return { ...result, authMode: authModeForToken(token), fallbackUsed: false };
   } catch (error) {
     const canFallback = fallbackToken && fallbackToken !== token && error && error.kind === 'auth';
     if (!canFallback) throw error;
-    return requestJson(url, fallbackToken, requestOptions);
+    const result = await requestJson(url, fallbackToken, requestOptions);
+    return { ...result, authMode: authModeForToken(fallbackToken), fallbackUsed: true };
   }
 }
 

+ 36 - 20
claude-code/claude-code-voc-intelligence/mcp/src/providers/voc-gateway.js

@@ -29,9 +29,11 @@ function pickBusinessStatus(json) {
   if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
     return topCode;
   }
-  const nestedCode = Number(json?.data?.status_code);
-  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
-    return nestedCode;
+  for (const value of [json?.data?.status_code, json?.data?.code]) {
+    const nestedCode = Number(value);
+    if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
+      return nestedCode;
+    }
   }
   return 0;
 }
@@ -46,13 +48,11 @@ function redactSecret(value) {
 }
 
 function isSuccessCode(json) {
-  const topCode = Number(json?.code);
-  if (Number.isFinite(topCode) && ![0, 200].includes(topCode)) {
-    return false;
-  }
-  const statusCode = Number(json?.data?.status_code);
-  if (Number.isFinite(statusCode) && ![0, 200].includes(statusCode)) {
-    return false;
+  for (const value of [json?.code, json?.data?.status_code, json?.data?.code]) {
+    const code = Number(value);
+    if (Number.isFinite(code) && ![0, 200].includes(code)) {
+      return false;
+    }
   }
   return true;
 }
@@ -68,19 +68,22 @@ function classifyApiError({ response, json, message }) {
   if (status === 401 || /invalid token|无效.*token|token.*invalid|未授权|unauthorized/i.test(text)) {
     return 'auth';
   }
-  if (status === 402 || /余额不足|额度不足|没开通|未开通|开通.*权限|权限.*额度|insufficient|balance|quota|payment|充值/i.test(text)) {
+  if (status === 402) {
     return 'billing';
   }
-  if (status === 403 && /余额不足|额度不足|没开通|未开通|insufficient|balance|quota|payment/i.test(text)) {
-    return 'billing';
+  if (status === 403) {
+    return 'permission';
   }
   if (/参数|入参|keyword|关键词|invalid request|bad request/i.test(text) || [400, 422].includes(status)) {
     return 'request';
   }
+  if (status === 202 || /not support|unsupported|不支持/i.test(text)) {
+    return 'unsupported';
+  }
   if (status >= 500) {
     return 'upstream';
   }
-  if (status === 403 || /permission|权限|无权限/i.test(text)) {
+  if (/permission|权限|无权限|未开通|没开通/i.test(text)) {
     return 'permission';
   }
   return 'upstream';
@@ -88,7 +91,13 @@ function classifyApiError({ response, json, message }) {
 
 const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
 
-async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {}) {
+function authModeForToken(token) {
+  if (/^sk-/i.test(String(token || '').trim())) return 'newapi_token';
+  if (/^r:/i.test(String(token || '').trim())) return 'session_newapi';
+  return 'unknown';
+}
+
+async function requestJson(url, token, { method = 'GET', body, retries = 3, headers = {} } = {}) {
   let lastError;
   for (let attempt = 0; attempt <= retries; attempt++) {
     let response;
@@ -99,6 +108,7 @@ async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {
         headers: {
           Authorization: `Bearer ${token}`,
           Accept: 'application/json',
+          ...headers,
           ...(body ? { 'Content-Type': 'application/json' } : {})
         },
         body: body ? JSON.stringify(body) : undefined
@@ -127,7 +137,9 @@ async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {
       error.httpStatus = pickBusinessStatus(json) || response.status || 500;
       error.kind = classifyApiError({ response, json, message });
       error.responseBody = redactSecret(text);
-      if (error.httpStatus >= 500 && attempt < retries) {
+      const retryableCollectionFailure = error.kind === 'upstream' &&
+        (error.httpStatus === 301 || /collect failed|send request again/i.test(message));
+      if ((error.httpStatus >= 500 || retryableCollectionFailure) && attempt < retries) {
         lastError = error;
         await delay(400 * (attempt + 1));
         continue;
@@ -142,21 +154,24 @@ async function requestJson(url, token, { method = 'GET', body, retries = 3 } = {
 // 优先用 newapi 的 sk- token 发请求;若该 token 鉴权失败(kind === 'auth',例如服务端
 // 尚未迁移 / 该号未入仓 / token 未被接受)且提供了回退 token(平台 sessionToken),自动用
 // 回退 token 重试一次。402(余额不足)/403(无权限)不回退。
-async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3 } = {}) {
+async function callSocialGateway({ proxyPath, method = 'GET', query = {}, body, token, fallbackToken, baseUrl, retries = 3, headers = {} } = {}) {
   const root = resolveSocialRoot(baseUrl);
   const url = buildGatewayUrl(root, proxyPath, query);
   const upper = String(method || 'GET').toUpperCase();
   const requestOptions = {
     method: upper,
     body: upper === 'GET' ? undefined : body,
-    retries
+    retries,
+    headers
   };
   try {
-    return await requestJson(url, token, requestOptions);
+    const result = await requestJson(url, token, requestOptions);
+    return { ...result, authMode: authModeForToken(token), fallbackUsed: false };
   } catch (error) {
     const canFallback = fallbackToken && fallbackToken !== token && error && error.kind === 'auth';
     if (!canFallback) throw error;
-    return requestJson(url, fallbackToken, requestOptions);
+    const result = await requestJson(url, fallbackToken, requestOptions);
+    return { ...result, authMode: authModeForToken(fallbackToken), fallbackUsed: true };
   }
 }
 
@@ -168,5 +183,6 @@ module.exports = {
   redactSecret,
   unwrapData,
   requestJson,
+  authModeForToken,
   callSocialGateway
 };

+ 22 - 98
claude-code/claude-code-voc-intelligence/mcp/src/providers/xiaohongshu-api.js

@@ -1,119 +1,43 @@
+const { callSocialGateway } = require('./voc-gateway');
+
 const VOC_SOCIAL_API_ROOT = 'https://server.fmode.cn/api/voc-social';
 const XIAOHONGSHU_APP_BASE_URL = `${VOC_SOCIAL_API_ROOT}/xiaohongshu/app`;
 
-function buildUrl(baseUrl, path, query = {}) {
-  const url = new URL(`${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`);
-  Object.entries(query).forEach(([key, value]) => {
-    if (value !== undefined && value !== null && value !== '') {
-      url.searchParams.set(key, String(value));
-    }
-  });
-  return url.toString();
-}
-
-function unwrapData(json) {
-  if (!json || typeof json !== 'object') return {};
-  return json.data?.data || json.data || json;
-}
-
-function pickBusinessStatus(json) {
-  const topCode = Number(json?.code);
-  if (Number.isFinite(topCode) && topCode !== 0 && topCode !== 200) {
-    return topCode;
-  }
-  const nestedCode = Number(json?.data?.status_code);
-  if (Number.isFinite(nestedCode) && nestedCode !== 0 && nestedCode !== 200) {
-    return nestedCode;
-  }
-  return 0;
-}
-
-function redactSecret(value) {
-  return String(value || '')
-    .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
-    .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
-    .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
-    .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
-    .replace(/("api[_-]?token"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3');
-}
-
-const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
-
-async function requestJson(url, token, { retries = 3 } = {}) {
-  let lastError;
-  for (let attempt = 0; attempt <= retries; attempt++) {
-    let response;
-    let text;
-    try {
-      response = await fetch(url, {
-        method: 'GET',
-        headers: {
-          Authorization: `Bearer ${token}`,
-          Accept: 'application/json'
-        }
-      });
-      text = await response.text();
-    } catch (networkError) {
-      lastError = networkError;
-      if (attempt < retries) {
-        await delay(400 * (attempt + 1));
-        continue;
-      }
-      throw networkError;
-    }
-    let json;
-    try {
-      json = JSON.parse(text);
-    } catch {
-      json = undefined;
-    }
-    const businessStatus = pickBusinessStatus(json);
-    if (!response.ok || businessStatus) {
-      const message = json?.message || json?.msg || json?.data?.msg || text || `HTTP ${response.status}`;
-      const error = new Error(redactSecret(message));
-      error.httpStatus = businessStatus || response.status || 500;
-      error.responseBody = redactSecret(text);
-      if (error.httpStatus >= 500 && attempt < retries) {
-        lastError = error;
-        await delay(400 * (attempt + 1));
-        continue;
-      }
-      throw error;
-    }
-    return { json, data: unwrapData(json), rawText: text };
-  }
-  throw lastError;
-}
-
 class XiaohongshuApi {
   constructor(options = {}) {
     this.baseUrl = options.baseUrl || process.env.XIAOHONGSHU_API_BASE_URL || XIAOHONGSHU_APP_BASE_URL;
     this.token = options.token;
+    this.fallbackToken = options.fallbackToken || '';
+    this.headers = options.headers || {};
   }
 
-  async searchNotes({ keyword, page = 1, sort = 'popularity_descending', noteType = '_0' }) {
-    const url = buildUrl(this.baseUrl, 'search_notes', {
+  request(proxyPath, query) {
+    return callSocialGateway({
+      proxyPath,
+      method: 'GET',
+      query,
+      token: this.token,
+      fallbackToken: this.fallbackToken,
+      baseUrl: this.baseUrl,
+      headers: this.headers
+    });
+  }
+
+  searchNotes({ keyword, page = 1, sort = 'popularity_descending', noteType = '_0' }) {
+    return this.request('search_notes', {
       keyword,
       page,
       sort,
       note_type: noteType
     });
-    return requestJson(url, this.token);
   }
 
-  async getNoteDetail({ noteId }) {
-    const url = buildUrl(this.baseUrl, 'get_note_info', {
-      note_id: noteId
-    });
-    return requestJson(url, this.token);
+  getNoteDetail({ noteId }) {
+    return this.request('get_note_info', { note_id: noteId });
   }
 
-  async getNoteComments({ noteId, cursor = '' }) {
-    const url = buildUrl(this.baseUrl, 'get_note_comments', {
-      note_id: noteId,
-      cursor
-    });
-    return requestJson(url, this.token);
+  getNoteComments({ noteId, cursor = '' }) {
+    return this.request('get_note_comments', { note_id: noteId, cursor });
   }
 }
 

+ 258 - 23
claude-code/claude-code-voc-intelligence/mcp/src/server.js

@@ -2,7 +2,7 @@
 const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
 const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
 const { z } = require('zod');
-const { readXiaohongshuToken, readVocToken, readNewApiToken } = require('./core/credentials');
+const { readCredentialContext } = require('./core/credentials');
 const { runXiaohongshuTrend } = require('./tools/xiaohongshu-trend-run');
 const { updateXiaohongshuPreference } = require('./tools/xiaohongshu-preference-update');
 const { runDouyinTrend } = require('./tools/douyin-trend-run');
@@ -15,7 +15,10 @@ const { runVocSpeakingScript } = require('./tools/voc-speaking-script-run');
 const { runBusinessWorkflow } = require('./tools/voc-business-workflow-run');
 const { analyzeFmodeImage } = require('./tools/fmode-image-analysis');
 const { searchVocApis, getVocApiDoc, callVocApi } = require('./tools/voc-api-catalog-run');
-const { buildVocRechargeInfo, buildMissingTokenMessage, buildMissingNewApiTokenMessage } = require('./core/payment-links');
+const { queryVocBilling } = require('./tools/voc-billing-query-run');
+const { runVocCostEstimate } = require('./tools/voc-cost-estimate-run');
+const { runVocUsageReport } = require('./tools/voc-usage-report-run');
+const { buildMissingNewApiTokenMessage } = require('./core/payment-links');
 const { primeUpdateCheck, applyUpdateNotice } = require('./core/version-check');
 
 function asToolResult(result) {
@@ -63,7 +66,7 @@ function normalizeToolResult(result = {}) {
 function createServer() {
   const server = new McpServer({
     name: 'voc-intelligence',
-    version: '0.3.24'
+    version: '0.3.33'
   });
 
   server.registerTool(
@@ -171,6 +174,25 @@ function createServer() {
         notesPerKeyword: z.number().int().min(1).max(10).optional(),
         videosPerKeyword: z.number().int().min(1).max(10).optional(),
         maxCommentPages: z.number().int().min(0).max(5).optional(),
+        accountCount: z.number().int().min(1).max(1000000).optional(),
+        keywordCount: z.number().int().min(1).max(1000000).optional(),
+        itemsPerKeyword: z.number().int().min(1).max(100).optional(),
+        commentPagesPerKeyword: z.number().int().min(0).max(100).optional(),
+        productCount: z.number().int().min(1).max(1000000).optional(),
+        skuCount: z.number().int().min(1).max(1000000).optional(),
+        budgetCny: z.number().min(0).optional(),
+        budgetLimitCny: z.number().min(0).optional(),
+        enforceBudget: z.boolean().optional(),
+        preflightEstimate: z.boolean().optional(),
+        requireEstimate: z.boolean().optional(),
+        usageTraceId: z.string().max(120).optional(),
+        reportId: z.string().max(120).optional(),
+        skillId: z.string().max(120).optional(),
+        workflowType: z.string().max(120).optional(),
+        clientId: z.string().max(120).optional(),
+        resume: z.boolean().optional(),
+        resumeFrom: z.string().optional(),
+        checkpointPath: z.string().optional(),
         xiaohongshuToken: z.string().optional(),
         douyinToken: z.string().optional(),
         vocToken: z.string().optional()
@@ -180,6 +202,10 @@ function createServer() {
         assistantMessage: z.string(),
         summary: z.object({}).passthrough().optional(),
         data: z.object({}).passthrough().optional(),
+        usage: z.object({}).passthrough().optional(),
+        estimate: z.object({}).passthrough().nullable().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        checkpoint: z.union([z.string(), z.object({}).passthrough()]).nullable().optional(),
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
@@ -456,15 +482,17 @@ function createServer() {
       }
     },
     async input => {
-      const token = readXiaohongshuToken(input) || readNewApiToken(input);
-      const recharge = token ? null : await buildVocRechargeInfo();
+      const credentials = readCredentialContext(input);
+      const configured = Boolean(credentials.newApiToken || credentials.sessionToken);
       const result = {
-        status: token ? 'ok' : 'needs_token',
-        configured: Boolean(token),
-        data: recharge ? { recharge } : {},
-        assistantMessage: token
+        status: configured ? 'ok' : 'needs_token',
+        configured,
+        data: {
+          canBuildPersonalBalanceUrl: credentials.capabilities.canBuildPersonalBalanceUrl
+        },
+        assistantMessage: configured
           ? '\u5c0f\u7ea2\u4e66 live \u91c7\u96c6 Token \u5df2\u914d\u7f6e\uff0c\u53ef\u4ee5\u5c1d\u8bd5\u5c0f\u89c4\u6a21\u771f\u5b9e\u91c7\u96c6\u3002'
-          : buildMissingNewApiTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '\u5c0f\u7ea2\u4e66' })
+          : buildMissingNewApiTokenMessage('', {}, { platformLabel: '\u5c0f\u7ea2\u4e66' })
       };
       return asToolResult(result);
     }
@@ -488,15 +516,17 @@ function createServer() {
       }
     },
     async input => {
-      const token = readVocToken(input) || readNewApiToken(input);
-      const recharge = token ? null : await buildVocRechargeInfo();
+      const credentials = readCredentialContext(input);
+      const configured = Boolean(credentials.newApiToken || credentials.sessionToken);
       const result = {
-        status: token ? 'ok' : 'needs_token',
-        configured: Boolean(token),
-        data: recharge ? { recharge } : {},
-        assistantMessage: token
+        status: configured ? 'ok' : 'needs_token',
+        configured,
+        data: {
+          canBuildPersonalBalanceUrl: credentials.capabilities.canBuildPersonalBalanceUrl
+        },
+        assistantMessage: configured
           ? '\u6296\u97f3 live \u91c7\u96c6 Token \u5df2\u914d\u7f6e\uff0c\u53ef\u4ee5\u5f00\u59cb\u5c0f\u89c4\u6a21\u771f\u5b9e\u91c7\u96c6\u3002'
-          : buildMissingNewApiTokenMessage(recharge.paymentUrl, recharge, { platformLabel: '\u6296\u97f3' })
+          : buildMissingNewApiTokenMessage('', {}, { platformLabel: '\u6296\u97f3' })
       };
       return asToolResult(result);
     }
@@ -527,12 +557,30 @@ function createServer() {
         keywordLimit: z.number().int().min(1).max(10).optional(),
         notesPerKeyword: z.number().int().min(1).max(10).optional(),
         maxCommentPages: z.number().int().min(0).max(5).optional(),
+        keywordCount: z.number().int().min(1).max(1000000).optional(),
+        itemsPerKeyword: z.number().int().min(1).max(100).optional(),
+        commentPagesPerKeyword: z.number().int().min(0).max(100).optional(),
+        budgetCny: z.number().min(0).optional(),
+        budgetLimitCny: z.number().min(0).optional(),
+        rechargeBufferCny: z.number().min(0).optional(),
+        enforceBudget: z.boolean().optional(),
+        preflightEstimate: z.boolean().optional(),
+        requireEstimate: z.boolean().optional(),
         sort: z.enum(['general', 'time_descending', 'popularity_descending']).optional(),
         noteType: z.enum(['_0', '_1', '_2']).optional(),
         cacheAssets: z.boolean().optional().describe('Whether to cache evidence images into the output assets directory. Defaults to true.'),
         assetLimit: z.number().int().min(0).max(30).optional().describe('Maximum number of note cover images to cache.'),
         xiaohongshuToken: z.string().optional().describe('Optional request-scoped collection token. It is used only for this run and never echoed back.'),
-        vocToken: z.string().optional().describe('Optional VOC social token, same convention as douyin-speaking-daily. It is used only for this run and never echoed back.')
+        vocToken: z.string().optional().describe('Optional VOC social token, same convention as douyin-speaking-daily. It is used only for this run and never echoed back.'),
+        usageTraceId: z.string().max(120).optional(),
+        reportId: z.string().max(120).optional(),
+        skillId: z.string().max(120).optional(),
+        workflowType: z.string().max(120).optional(),
+        stepId: z.string().max(120).optional(),
+        clientId: z.string().max(120).optional(),
+        resume: z.boolean().optional(),
+        resumeFrom: z.string().optional(),
+        checkpointPath: z.string().optional()
       },
       outputSchema: {
         status: z.string(),
@@ -542,7 +590,11 @@ function createServer() {
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
-        errors: z.array(z.any()).optional()
+        errors: z.array(z.any()).optional(),
+        usage: z.object({}).passthrough().optional(),
+        estimate: z.object({}).passthrough().nullable().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        checkpoint: z.union([z.string(), z.object({}).passthrough()]).nullable().optional()
       }
     },
     async input => {
@@ -579,6 +631,15 @@ function createServer() {
         keywordLimit: z.number().int().min(1).max(10).optional(),
         videosPerKeyword: z.number().int().min(1).max(10).optional(),
         maxCommentPages: z.number().int().min(0).max(5).optional(),
+        keywordCount: z.number().int().min(1).max(1000000).optional(),
+        itemsPerKeyword: z.number().int().min(1).max(100).optional(),
+        commentPagesPerKeyword: z.number().int().min(0).max(100).optional(),
+        budgetCny: z.number().min(0).optional(),
+        budgetLimitCny: z.number().min(0).optional(),
+        rechargeBufferCny: z.number().min(0).optional(),
+        enforceBudget: z.boolean().optional(),
+        preflightEstimate: z.boolean().optional(),
+        requireEstimate: z.boolean().optional(),
         sortType: z.enum(['0', '1', '2']).optional(),
         publishTime: z.enum(['0', '1', '7', '180']).optional(),
         filterDuration: z.enum(['0', '0-1', '1-5', '5-10000']).optional(),
@@ -586,7 +647,16 @@ function createServer() {
         cacheAssets: z.boolean().optional().describe('Whether to cache evidence images into the output assets directory. Defaults to true.'),
         assetLimit: z.number().int().min(0).max(30).optional().describe('Maximum number of video cover images to cache.'),
         douyinToken: z.string().optional().describe('Optional request-scoped collection token. It is used only for this run and never echoed back.'),
-        vocToken: z.string().optional().describe('Optional VOC social token, same convention as xiaohongshu trend. It is used only for this run and never echoed back.')
+        vocToken: z.string().optional().describe('Optional VOC social token, same convention as xiaohongshu trend. It is used only for this run and never echoed back.'),
+        usageTraceId: z.string().max(120).optional(),
+        reportId: z.string().max(120).optional(),
+        skillId: z.string().max(120).optional(),
+        workflowType: z.string().max(120).optional(),
+        stepId: z.string().max(120).optional(),
+        clientId: z.string().max(120).optional(),
+        resume: z.boolean().optional(),
+        resumeFrom: z.string().optional(),
+        checkpointPath: z.string().optional()
       },
       outputSchema: {
         status: z.string(),
@@ -596,7 +666,11 @@ function createServer() {
         files: z.array(z.string()).optional(),
         nextActions: z.array(z.string()).optional(),
         warnings: z.array(z.any()).optional(),
-        errors: z.array(z.any()).optional()
+        errors: z.array(z.any()).optional(),
+        usage: z.object({}).passthrough().optional(),
+        estimate: z.object({}).passthrough().nullable().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        checkpoint: z.union([z.string(), z.object({}).passthrough()]).nullable().optional()
       }
     },
     async input => {
@@ -755,7 +829,14 @@ function createServer() {
         vocToken: z.string().optional().describe('Optional VOC social r: session token. Used only for this run and never echoed back.'),
         token: z.string().optional().describe('Alias of vocToken. Never echoed back.'),
         douyinToken: z.string().optional(),
-        xiaohongshuToken: z.string().optional()
+        xiaohongshuToken: z.string().optional(),
+        usageTraceId: z.string().max(120).optional(),
+        reportId: z.string().max(120).optional(),
+        skillId: z.string().max(120).optional(),
+        workflowType: z.string().max(120).optional(),
+        stepId: z.string().max(120).optional(),
+        clientId: z.string().max(120).optional(),
+        outputDir: z.string().optional()
       },
       outputSchema: {
         status: z.string(),
@@ -771,6 +852,161 @@ function createServer() {
     async input => asToolResult(await callVocApi(input))
   );
 
+  server.registerTool(
+    'voc_billing_query',
+    {
+      title: 'Query VOC Billing Estimate',
+      description: [
+        'Query the current NewAPI balance and server-configured VOC prices for a planned set of interface calls.',
+        'Use before expensive reports to estimate quota, user cost, shortage, and suggested recharge. This endpoint only reads balance and pricing; it never charges or creates a task payment.'
+      ].join(' '),
+      inputSchema: {
+        operations: z.array(z.object({
+          model: z.string().min(1),
+          apigId: z.string().optional(),
+          path: z.string().optional(),
+          count: z.number().int().min(1).max(1000000).optional(),
+          quota: z.number().int().positive().optional(),
+          label: z.string().max(120).optional()
+        })).min(1).max(100),
+        rechargeBufferCny: z.number().min(0).max(100000).optional(),
+        createPaymentQr: z.boolean().optional().describe('When balance is insufficient and a Session Token is available, create a QR payment order through the existing pay_code2 flow.'),
+        baseUrl: z.string().url().optional(),
+        title: z.string().max(80).optional(),
+        newapiToken: z.string().optional(),
+        newApiToken: z.string().optional(),
+        fmodeApiKey: z.string().optional(),
+        sessionToken: z.string().optional(),
+        vocToken: z.string().optional(),
+        token: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await queryVocBilling(input))
+  );
+
+  server.registerTool(
+    'voc_cost_estimate',
+    {
+      title: 'Estimate VOC Workflow Cost',
+      description: [
+        'Build economy, standard, and complete plans for a VOC task and price them with the current server billing rules.',
+        'Use before large account, product, keyword, or report collection jobs. The estimate reads balance and pricing only; it never creates a task payment or charges the account.'
+      ].join(' '),
+      inputSchema: {
+        task: z.string().max(500).optional(),
+        workflowType: z.string().max(100).optional(),
+        mode: z.enum(['economy', 'standard', 'complete', 'low', 'full', 'all']).optional(),
+        accountCount: z.number().int().min(1).max(1000000).optional(),
+        keywordCount: z.number().int().min(1).max(1000000).optional(),
+        itemsPerKeyword: z.number().int().min(1).max(100).optional(),
+        commentPagesPerKeyword: z.number().int().min(0).max(100).optional(),
+        unresolvedAccountCount: z.number().int().min(0).max(1000000).optional(),
+        notesPerAccount: z.number().int().min(1).max(100).optional(),
+        detailNotesPerAccount: z.number().int().min(0).max(100).optional(),
+        commentPagesPerNote: z.number().int().min(0).max(100).optional(),
+        productCount: z.number().int().min(1).max(1000000).optional(),
+        skuCount: z.number().int().min(1).max(1000000).optional(),
+        shopListPages: z.number().int().min(1).max(100000).optional(),
+        representativeSkuCount: z.number().int().min(1).max(1000000).optional(),
+        commentPagesPerItem: z.number().int().min(0).max(100).optional(),
+        operations: z.array(z.object({
+          model: z.string().min(1),
+          apigId: z.string().optional(),
+          path: z.string().optional(),
+          endpointId: z.string().optional(),
+          count: z.number().int().min(1).max(1000000).optional(),
+          label: z.string().max(120).optional(),
+          valueScore: z.number().min(0).max(5).optional(),
+          required: z.boolean().optional()
+        }).passthrough()).max(100).optional(),
+        budgetCny: z.number().min(0).optional(),
+        budgetLimitCny: z.number().min(0).optional(),
+        enforceBudget: z.boolean().optional(),
+        rechargeBufferCny: z.number().min(0).optional(),
+        outputDir: z.string().optional(),
+        baseUrl: z.string().url().optional(),
+        newapiToken: z.string().optional(),
+        newApiToken: z.string().optional(),
+        fmodeApiKey: z.string().optional(),
+        sessionToken: z.string().optional(),
+        vocToken: z.string().optional(),
+        token: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runVocCostEstimate(input))
+  );
+
+  server.registerTool(
+    'voc_usage_report',
+    {
+      title: 'Report VOC Actual Usage',
+      description: [
+        'Read actual NewAPI usage grouped by the current user, usage trace, report, workflow, module, or date range.',
+        'Returns call frequency, billed calls, quota, CNY cost, quality marker, and endpoint breakdown. It does not expose upstream credentials or request payloads.'
+      ].join(' '),
+      inputSchema: {
+        usageTraceId: z.string().max(120).optional(),
+        reportId: z.string().max(120).optional(),
+        workflowType: z.string().max(100).optional(),
+        skillId: z.string().max(120).optional(),
+        stepId: z.string().max(120).optional(),
+        from: z.string().max(40).optional(),
+        to: z.string().max(40).optional(),
+        module: z.string().max(100).optional(),
+        endpointId: z.string().max(200).optional(),
+        limit: z.number().int().min(1).max(1000).optional(),
+        groupBy: z.array(z.enum(['module', 'endpoint', 'usageTraceId', 'reportId', 'skillId', 'workflowType'])).max(6).optional(),
+        page: z.number().int().min(1).max(100000).optional(),
+        pageSize: z.number().int().min(1).max(200).optional(),
+        operations: z.array(z.object({ model: z.string().min(1), apigId: z.string().optional(), path: z.string().optional(), count: z.number().int().min(1).max(1000000).optional() }).passthrough()).max(100).optional(),
+        reportPath: z.string().optional(),
+        usagePath: z.string().optional(),
+        refresh: z.boolean().optional(),
+        baseUrl: z.string().url().optional(),
+        newapiToken: z.string().optional(),
+        newApiToken: z.string().optional(),
+        fmodeApiKey: z.string().optional(),
+        sessionToken: z.string().optional(),
+        vocToken: z.string().optional(),
+        token: z.string().optional()
+      },
+      outputSchema: {
+        status: z.string(),
+        assistantMessage: z.string(),
+        summary: z.object({}).passthrough().optional(),
+        data: z.object({}).passthrough().optional(),
+        recharge: z.object({}).passthrough().nullable().optional(),
+        files: z.array(z.string()).optional(),
+        nextActions: z.array(z.string()).optional(),
+        warnings: z.array(z.any()).optional(),
+        errors: z.array(z.any()).optional()
+      }
+    },
+    async input => asToolResult(await runVocUsageReport(input))
+  );
+
   return server;
 }
 
@@ -791,4 +1027,3 @@ if (require.main === module) {
 module.exports = {
   createServer
 };
-

+ 265 - 49
claude-code/claude-code-voc-intelligence/mcp/src/tools/douyin-trend-run.js

@@ -2,18 +2,21 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { readVocToken, readNewApiToken } = require('../core/credentials');
+const { readCredentialContext } = require('../core/credentials');
 const { buildSampleDataset } = require('../features/douyin-trend/sample-data');
 const { collectLiveDataset } = require('../features/douyin-trend/live-collector');
 const { cacheEvidenceAssets } = require('../features/xiaohongshu-trend/assets');
+const { processMedia, replaceMediaUrls } = require('../features/media/media-pipeline');
 const { buildTrendReport, writeReport } = require('../features/douyin-trend/report');
 const { trendMemoryPath, readTrendMemory } = require('../features/douyin-trend/preference-memory');
 const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
+const { contextForTool, createStepContext, usageHeaders, writeUsageContext } = require('../core/usage-context');
+const { createBatchBudgetGuard, findBudgetIssue, budgetStoppedMessage } = require('../core/batch-budget-guard');
+const { runVocCostEstimate } = require('./voc-cost-estimate-run');
 const {
   buildVocRechargeInfo,
   buildMissingNewApiTokenMessage,
-  buildRechargeRequiredMessage,
-  buildFmodeApiRechargeUrl
+  buildRechargeRequiredMessage
 } = require('../core/payment-links');
 
 function parseArgs(argv) {
@@ -74,13 +77,36 @@ function readJsonIfExists(filePath) {
   return JSON.parse(fs.readFileSync(absolute, 'utf8'));
 }
 
+function readCheckpoint(outputDir, input = {}) {
+  if (!(input.resume || input.resumeFrom || input.checkpointPath)) return null;
+  const file = path.resolve(input.resumeFrom || input.checkpointPath || path.join(outputDir, 'checkpoint.json'));
+  try {
+    const value = JSON.parse(fs.readFileSync(file, 'utf8'));
+    return value && typeof value === 'object' ? value : null;
+  } catch { return null; }
+}
+
+function writeCheckpoint(outputDir, context, dataset, completedKeywords = [], pendingKeywords = []) {
+  const file = path.join(outputDir, 'checkpoint.json');
+  fs.mkdirSync(outputDir, { recursive: true });
+  fs.writeFileSync(file, JSON.stringify({
+    usageTraceId: context.usageTraceId,
+    reportId: context.reportId,
+    completedKeywords,
+    pendingKeywords,
+    dataset,
+    savedAt: new Date().toISOString()
+  }, null, 2));
+  return file;
+}
+
 const AUTH_IDENTITY_RE = /company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|Invalid API token|无效的?API?令牌|无效.*token|token.*invalid|未授权|unauthorized|请输入.*token|sessiontoken/i;
 
 function findAuthError(errors = []) {
   return errors.find(error => {
     const message = String(error && error.message || '');
     const kind = String(error && (error.kind || error.errorKind) || '');
-    if (['request', 'upstream_search', 'invalid_keyword', 'billing', 'permission'].includes(kind)) return false;
+    if (['request', 'upstream_search', 'invalid_keyword', 'billing', 'permission', 'budget'].includes(kind)) return false;
     return kind === 'auth' ||
       Number(error && error.httpStatus) === 401 ||
       AUTH_IDENTITY_RE.test(message);
@@ -92,14 +118,29 @@ function findGatewayError(errors = []) {
     const status = Number(error && error.httpStatus);
     const message = String(error && error.message || '');
     const kind = String(error && (error.kind || error.errorKind) || '');
-    if (['request', 'upstream_search', 'invalid_keyword'].includes(kind)) return false;
-    return status === 402 ||
-      kind === 'billing' ||
-      /没有开通|余额不足|权限或余额|quota|payment|insufficient|balance/i.test(message) ||
-      (status === 403 && /permission|无权限|权限/i.test(message));
+    if (['request', 'upstream_search', 'invalid_keyword', 'permission', 'budget'].includes(kind)) return false;
+    return status === 402 || kind === 'billing';
   });
 }
 
+function findPermissionError(errors = []) {
+  return errors.find(error => (
+    String(error && (error.kind || error.errorKind) || '') === 'permission' ||
+    Number(error && error.httpStatus) === 403
+  ));
+}
+
+function rechargeStatusFor(recharge, partial = false) {
+  if (recharge?.personalized) return partial ? 'partial_needs_recharge' : 'needs_recharge';
+  return partial ? 'partial_needs_session' : 'needs_session';
+}
+
+function rechargeActionFor(recharge) {
+  return recharge?.personalized
+    ? `打开免登录充值链接:${recharge.balanceUrl}`
+    : '恢复当前用户 Session Token 后生成个人充值链接或支付二维码';
+}
+
 function findSearchInputError(errors = []) {
   return errors.find(error => {
     const status = Number(error && error.httpStatus);
@@ -165,16 +206,43 @@ function loadProfile(args) {
   };
 }
 
+async function runCostEstimateSafely(input) {
+  try {
+    return await runVocCostEstimate(input);
+  } catch (error) {
+    return {
+      status: 'upstream_unstable',
+      assistantMessage: `执行前成本测算暂时不可用:${error.message || String(error)}`,
+      summary: { errorKind: 'estimate_failed' },
+      data: {},
+      files: [],
+      nextActions: ['稍后重试成本测算'],
+      warnings: [],
+      errors: []
+    };
+  }
+}
+
 async function runDouyinTrend(input = {}) {
   const collectionMode = input.collectionMode || input['collection-mode'] || 'sample';
   const outputDir = path.resolve(input.output || path.join('outputs', 'claude-code-douyin-trend', new Date().toISOString().slice(0, 10)));
+  const usageContext = contextForTool({ ...input, skillId: input.skillId || 'douyin-trend-intelligence', workflowType: input.workflowType || 'douyin_trend_report' });
+  const stepContext = createStepContext(usageContext, input.stepId || 'collect_douyin');
+  writeUsageContext(usageContext, outputDir);
   const profile = loadProfile(input);
   const memoryPath = trendMemoryPath(input);
+  const checkpoint = readCheckpoint(outputDir, input);
   const memory = readTrendMemory(memoryPath);
   const warnings = [];
   const errors = [];
   const assetFiles = [];
   let dataset;
+  let liveDataset = { completedKeywords: [] };
+  let checkpointFile = null;
+  let batchBudget = null;
+  let budgetIssue = null;
+  let rechargeSessionToken = '';
+  let estimateResult = null;
 
   if (collectionMode === 'sample') {
     dataset = buildSampleDataset(profile);
@@ -182,7 +250,12 @@ async function runDouyinTrend(input = {}) {
   } else if (collectionMode === 'live') {
     // 计费优先走 newapi 的 sk- token(与 voc_api_call 一致,server.fmode.cn/api/voc-social 同一网关
     // 已验证 Bearer sk- 可采集),读不到再回退平台会话 token。不再把 sk- 误判为「类型错误」。
-    const token = readNewApiToken(input) || readVocToken(input);
+    const credentials = readCredentialContext(input);
+    rechargeSessionToken = credentials.sessionToken || '';
+    const token = credentials.newApiToken || credentials.sessionToken;
+    const fallbackToken = credentials.newApiToken && credentials.sessionToken
+      ? credentials.sessionToken
+      : '';
     if (!token) {
       const recharge = await buildVocRechargeInfo();
       return {
@@ -196,8 +269,7 @@ async function runDouyinTrend(input = {}) {
         files: [],
         nextActions: [
           '从 ~/.claude/settings.json 读取 env.ANTHROPIC_AUTH_TOKEN(sk- 开头)或用 FMODE_API_KEY/newapiToken 入参重试',
-          '仍读不到时,再回退 r: 开头的会话 token,或确认是否真的 402 余额不足',
-          `确认确实是 fmode-api 余额不足/未开通时,再前往充值:${buildFmodeApiRechargeUrl()}`,
+          '仍读不到时,再恢复当前用户的 Session Token;缺凭证不是余额不足',
           '如果只是录制教学,可以先用 sample 模式演示完整流程'
         ],
         warnings: [],
@@ -205,9 +277,47 @@ async function runDouyinTrend(input = {}) {
       };
     }
 
-    const liveDataset = await collectLiveDataset({
+    const keywordLimit = Math.max(1, Number(input.keywordLimit ?? input['keyword-limit'] ?? 1));
+    const keywordCount = Math.min(profile.keywords.length || 1, keywordLimit);
+    const shouldEstimate = input.preflightEstimate === true || input.preflightEstimate === 'true' ||
+      input.budgetCny != null || input.budgetLimitCny != null || keywordCount > 20;
+    if (shouldEstimate) {
+      estimateResult = await runCostEstimateSafely({
+        ...input,
+        workflowType: input.workflowType || 'douyin_trend_report',
+        keywordCount,
+        itemsPerKeyword: input.videosPerKeyword || input['videos-per-keyword'],
+        commentPagesPerKeyword: input.maxCommentPages || input['max-comment-pages'],
+        usageTraceId: usageContext.usageTraceId,
+        reportId: usageContext.reportId,
+        skillId: usageContext.skillId,
+        baseUrl: input.baseUrl || input['base-url'],
+        outputDir
+      });
+      if (estimateResult && estimateResult.status !== 'ok' && estimateResult.assistantMessage) {
+        warnings.push(`执行前成本测算提示:${estimateResult.assistantMessage}`);
+      }
+      if (estimateResult && estimateResult.status !== 'ok' && input.requireEstimate === true) return estimateResult;
+    }
+
+    const estimateInsufficient = Boolean(
+      estimateResult?.summary?.sufficient === false ||
+      estimateResult?.data?.shortages?.some(item => item.sufficient === false)
+    );
+    const batchGuard = createBatchBudgetGuard({
+      input: { ...input, ...(estimateInsufficient ? { enforceBudget: true } : {}) },
+      context: usageContext,
+      operationFactory: ({ keyword }) => [
+        { model: 'voc-social', path: 'search/fetch_general_search_v2', count: 1, label: `搜索关键词:${keyword}` },
+        { model: 'voc-social', path: 'app/v3/fetch_video_comments', count: Number(input.videosPerKeyword ?? input['videos-per-keyword'] ?? 1) * Number(input.maxCommentPages ?? input['max-comment-pages'] ?? 1), label: '入选视频评论' }
+      ]
+    });
+    batchBudget = batchGuard;
+
+    liveDataset = await collectLiveDataset({
       profile,
       token,
+      fallbackToken,
       options: {
         keywordLimit: input.keywordLimit || input['keyword-limit'],
         videosPerKeyword: input.videosPerKeyword || input['videos-per-keyword'],
@@ -216,12 +326,15 @@ async function runDouyinTrend(input = {}) {
         publishTime: input.publishTime || input['publish-time'],
         filterDuration: input.filterDuration || input['filter-duration'],
         contentType: input.contentType || input['content-type'],
-        baseUrl: input.baseUrl || input['base-url'] || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL
+        skipKeywords: checkpoint?.completedKeywords || [],
+        baseUrl: input.baseUrl || input['base-url'] || process.env.DOUYIN_API_BASE_URL || process.env.VOC_DOUYIN_API_BASE_URL,
+        headers: usageHeaders(stepContext),
+        beforeBatch: batchGuard.beforeBatch
       }
     });
     dataset = {
-      videos: liveDataset.videos,
-      comments: liveDataset.comments
+      videos: [...(checkpoint?.dataset?.videos || []), ...liveDataset.videos],
+      comments: [...(checkpoint?.dataset?.comments || []), ...liveDataset.comments]
     };
     if (liveDataset.keywords?.length) {
       profile.keywords = liveDataset.keywords;
@@ -230,25 +343,24 @@ async function runDouyinTrend(input = {}) {
     errors.push(...liveDataset.errors);
 
     const collectionIssue = findGatewayError([...errors, ...warnings]);
+    budgetIssue = findBudgetIssue(errors);
     if (collectionIssue) {
-      const recharge = await buildVocRechargeInfo({ token });
+      const recharge = await buildVocRechargeInfo({ sessionToken: credentials.sessionToken });
       if (dataset.videos.length) {
-        const partialCacheAssetsEnabled = input.cacheAssets !== false &&
-          input['cache-assets'] !== false &&
-          String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
-        if (partialCacheAssetsEnabled) {
-          const assetResult = await cacheEvidenceAssets({
-            notes: dataset.videos,
-            outputDir,
-            limit: input.assetLimit ?? input['asset-limit'] ?? 10
-          });
-          warnings.push(...assetResult.warnings);
-          assetFiles.push(...assetResult.files);
-        }
+        const mediaResult = await processMedia(dataset, {
+          outputDir,
+          usageTraceId: usageContext.usageTraceId,
+          platform: 'douyin',
+          maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+        });
+        dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+        warnings.push(...mediaResult.warnings);
+        assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
         const report = buildTrendReport({ profile, dataset, collectionMode, memory });
         const files = writeReport(outputDir, report);
+        checkpointFile = writeCheckpoint(outputDir, usageContext, dataset, [ ...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || []) ], profile.keywords || []);
         return {
-          status: 'partial_needs_recharge',
+          status: rechargeStatusFor(recharge, true),
           assistantMessage: [
             buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
             '',
@@ -270,9 +382,13 @@ async function runDouyinTrend(input = {}) {
             recharge,
             memoryPath
           },
-          files: [...files, ...assetFiles],
+          usage: usageContext,
+          estimate: estimateResult?.data || estimateResult || null,
+          recharge,
+          checkpoint: checkpointFile,
+          files: [...files, ...assetFiles, checkpointFile],
           nextActions: [
-            `打开充值链接:${recharge.paymentUrl}`,
+            rechargeActionFor(recharge),
             '支付完成后用同一组关键词继续补采',
             '先基于已保存的部分样本做初步问题池,但不要当最终结论'
           ],
@@ -281,7 +397,7 @@ async function runDouyinTrend(input = {}) {
         };
       }
       return {
-        status: 'needs_recharge',
+        status: rechargeStatusFor(recharge),
         assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
         summary: {
           videoCount: dataset.videos.length,
@@ -293,9 +409,10 @@ async function runDouyinTrend(input = {}) {
           profile,
           recharge
         },
+        recharge,
         files: [],
         nextActions: [
-          `打开充值链接:${recharge.paymentUrl}`,
+          rechargeActionFor(recharge),
           '支付完成后重新运行本次抖音 live 采集',
           '如果需要先给客户演示,可以切换 sample 模式继续'
         ],
@@ -305,6 +422,41 @@ async function runDouyinTrend(input = {}) {
     }
 
     if (!dataset.videos.length) {
+      if (budgetIssue) {
+        const recharge = budgetIssue.budget?.status === 'needs_recharge'
+          ? await buildVocRechargeInfo({
+            sessionToken: rechargeSessionToken,
+            shortageCny: budgetIssue.budget.shortfallCny,
+            suggestedRechargeCny: budgetIssue.budget.suggestedRechargeCny
+          })
+          : null;
+        checkpointFile = writeCheckpoint(
+          outputDir,
+          usageContext,
+          dataset,
+          checkpoint?.completedKeywords || [],
+          profile.keywords.filter(keyword => !(checkpoint?.completedKeywords || []).includes(keyword))
+        );
+        return {
+          status: recharge ? rechargeStatusFor(recharge) : 'budget_blocked',
+          assistantMessage: [
+            budgetStoppedMessage(budgetIssue.budget),
+            recharge ? buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }) : ''
+          ].filter(Boolean).join('\n\n'),
+          summary: { videoCount: 0, commentCount: dataset.comments.length, failedStage: 'budget_guard', failedKeyword: budgetIssue.keyword || '', batchBudget: batchBudget ? batchBudget.snapshot() : null },
+          data: { profile, ...(recharge ? { recharge } : {}), batchBudget: batchBudget ? batchBudget.snapshot() : null },
+          usage: usageContext,
+          estimate: estimateResult?.data || estimateResult || null,
+          ...(recharge ? { recharge } : {}),
+          checkpoint: checkpointFile,
+          files: [checkpointFile],
+          nextActions: recharge
+            ? [rechargeActionFor(recharge), '支付完成后使用相同参数从 checkpoint.json 继续本次抖音 live 采集']
+            : ['缩小采集范围或提高本地预算后,从 checkpoint.json 继续本次抖音 live 采集'],
+          warnings,
+          errors: []
+        };
+      }
       const searchInputError = findSearchInputError(errors);
       if (searchInputError) {
         return {
@@ -331,7 +483,6 @@ async function runDouyinTrend(input = {}) {
 
       const authError = findAuthError(errors);
       if (authError) {
-        const recharge = await buildVocRechargeInfo({ token });
         return {
           status: 'needs_valid_token',
           assistantMessage: [
@@ -341,7 +492,7 @@ async function runDouyinTrend(input = {}) {
             '',
             '请先确认 ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(sk- 开头、base 指向 fmode)有效;仍失败时再回退 r: 会话 token,或确认是否 402 余额不足。',
             '',
-            `(仅当确认余额不足时才充值:${recharge.paymentUrl})`
+            '只有接口明确返回 402 时才会提供当前用户充值入口。'
           ].join('\n'),
           summary: {
             videoCount: 0,
@@ -350,11 +501,10 @@ async function runDouyinTrend(input = {}) {
             failedStage: authError.stage || 'search_videos',
             failedKeyword: authError.keyword || ''
           },
-          data: { profile, recharge },
+          data: { profile },
           files: [],
           nextActions: [
-            `打开充值/开通链接:${recharge.paymentUrl}`,
-            '重新配置有效 session token 后重试 live 模式',
+            '恢复有效 NewAPI Key 或 Session Token 后重试 live 模式',
             '需要录教学视频时,可先用 sample 模式演示完整多轮校准流程'
           ],
           warnings: [],
@@ -364,9 +514,9 @@ async function runDouyinTrend(input = {}) {
 
       const gatewayError = findGatewayError(errors);
       if (gatewayError) {
-        const recharge = await buildVocRechargeInfo({ token });
+        const recharge = await buildVocRechargeInfo({ sessionToken: credentials.sessionToken });
         return {
-          status: 'needs_recharge',
+          status: rechargeStatusFor(recharge),
           assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: '抖音' }),
           summary: {
             videoCount: 0,
@@ -375,9 +525,10 @@ async function runDouyinTrend(input = {}) {
             failedKeyword: gatewayError.keyword || ''
           },
           data: { profile, recharge },
+          recharge,
           files: [],
           nextActions: [
-            `打开充值链接:${recharge.paymentUrl}`,
+            rechargeActionFor(recharge),
             '支付完成后重新运行本次抖音 live 采集',
             '如果需要先给客户演示,可以切换 sample 模式继续'
           ],
@@ -386,6 +537,25 @@ async function runDouyinTrend(input = {}) {
         };
       }
 
+      const permissionError = findPermissionError(errors);
+      if (permissionError) {
+        return {
+          status: 'needs_permission',
+          assistantMessage: '当前账号没有抖音数据接口权限,或账号已被禁用。这不是余额不足,请确认账号和接口权限后重试。',
+          summary: {
+            videoCount: 0,
+            commentCount: dataset.comments.length,
+            failedStage: permissionError.stage || 'search_videos',
+            failedKeyword: permissionError.keyword || ''
+          },
+          data: { profile },
+          files: [],
+          nextActions: ['确认当前账号状态和抖音接口权限后重试'],
+          warnings: [],
+          errors: []
+        };
+      }
+
       const upstreamError = findUpstreamError(errors);
       if (upstreamError) {
         return {
@@ -450,7 +620,24 @@ async function runDouyinTrend(input = {}) {
   const cacheAssetsEnabled = input.cacheAssets !== false &&
     input['cache-assets'] !== false &&
     String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
-  if (cacheAssetsEnabled) {
+  if (collectionMode === 'live') {
+    const mediaResult = await processMedia(dataset, {
+      outputDir,
+      usageTraceId: usageContext.usageTraceId,
+      platform: 'douyin',
+      maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+    });
+    dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+    warnings.push(...mediaResult.warnings);
+    assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
+    checkpointFile = writeCheckpoint(
+      outputDir,
+      usageContext,
+      dataset,
+      [...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || [])],
+      []
+    );
+  } else if (cacheAssetsEnabled) {
     const assetResult = await cacheEvidenceAssets({
       notes: dataset.videos,
       outputDir,
@@ -476,17 +663,46 @@ async function runDouyinTrend(input = {}) {
       '再基于用户反馈收敛下一轮趋势判断'
     ];
 
+  const budgetRecharge = budgetIssue?.budget?.status === 'needs_recharge'
+    ? await buildVocRechargeInfo({
+      sessionToken: rechargeSessionToken || input.sessionToken || input.vocToken,
+      shortageCny: budgetIssue.budget.shortfallCny,
+      suggestedRechargeCny: budgetIssue.budget.suggestedRechargeCny
+    })
+    : null;
+  const budgetStatus = budgetIssue
+    ? (budgetIssue.budget?.status === 'needs_recharge'
+      ? (budgetRecharge?.personalized ? 'partial_needs_recharge' : 'partial_needs_session')
+      : 'budget_blocked')
+    : 'ok';
+  const budgetMessage = budgetIssue
+    ? `${budgetStoppedMessage(budgetIssue.budget)}${budgetRecharge ? `\n\n${buildRechargeRequiredMessage(budgetRecharge.paymentUrl, budgetRecharge, { platformLabel: '抖音' })}` : ''}\n\n`
+    : '';
+
   return okResult({
-    assistantMessage: report.assistantMessage,
-    summary: report.summary,
+    status: budgetStatus,
+    assistantMessage: `${budgetMessage}${report.assistantMessage}`,
+    summary: { ...report.summary, ...(batchBudget ? { batchBudget: batchBudget.snapshot() } : {}) },
     data: {
       ...report.data,
-      memoryPath
+      memoryPath,
+      usage: usageContext,
+      ...(estimateResult ? { estimate: estimateResult.data || estimateResult } : {}),
+      ...(batchBudget ? { batchBudget: batchBudget.snapshot() } : {})
     },
-    files: [...files, ...assetFiles],
-    nextActions,
+    files: [...files, ...assetFiles, path.join(outputDir, 'usage-context.json'), ...(checkpointFile ? [checkpointFile] : [])],
+    usage: usageContext,
+    ...(estimateResult ? { estimate: estimateResult.data || estimateResult } : {}),
+    ...(budgetRecharge ? { recharge: budgetRecharge } : {}),
+    checkpoint: checkpointFile,
+    nextActions: budgetIssue
+      ? [
+        ...(budgetRecharge?.personalized ? [rechargeActionFor(budgetRecharge), '支付完成后使用相同参数从 checkpoint.json 继续本次抖音 live 采集'] : ['缩小采集范围或提高本地预算后,从 checkpoint.json 继续本次抖音 live 采集']),
+        ...nextActions
+      ]
+      : nextActions,
     warnings,
-    errors
+    errors: []
   });
 }
 

+ 103 - 27
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-api-catalog-run.js

@@ -1,4 +1,4 @@
-const { readVocToken, readNewApiToken } = require('../core/credentials');
+const { readCredentialContext } = require('../core/credentials');
 const {
   listEndpoints,
   findEndpoint,
@@ -12,17 +12,12 @@ const { callEcommerceGateway } = require('../providers/ecommerce-gateway');
 const { callOverseasGateway } = require('../providers/overseas-gateway');
 const {
   buildVocRechargeInfo,
-  buildMissingTokenMessage,
   buildMissingNewApiTokenMessage,
-  buildFmodeApiRechargeUrl,
-  buildFmodeApiRechargeMessage,
+  buildRechargeRequiredMessage,
   buildPermissionDeniedMessage
 } = require('../core/payment-links');
 const { okResult, errorResult } = require('../core/result-envelope');
-
-function isSkToken(token) {
-  return Boolean(token) && /^\s*sk-/i.test(token);
-}
+const { contextForTool, createStepContext, usageHeaders, writeUsageContext } = require('../core/usage-context');
 
 function buildCallTemplate(endpoint) {
   const args = { id: endpoint.id, params: {} };
@@ -155,6 +150,32 @@ function buildRawRequestParts(input) {
   return { method, query: explicitQuery || {}, body: explicitBody || params };
 }
 
+function findRecordTime(value, depth = 0) {
+  if (!value || typeof value !== 'object' || depth > 5) return '';
+  if (typeof value.recordTime === 'string' && value.recordTime.trim()) {
+    return value.recordTime.trim();
+  }
+  for (const nested of Object.values(value)) {
+    const found = findRecordTime(nested, depth + 1);
+    if (found) return found;
+  }
+  return '';
+}
+
+function assessFreshness(json, endpoint, now = Date.now()) {
+  const recordTime = findRecordTime(json);
+  const maxAgeHours = Number(endpoint?.maxRecommendedAgeHours);
+  if (!recordTime || !Number.isFinite(maxAgeHours) || maxAgeHours <= 0) {
+    return { recordTime: recordTime || null, maxAgeHours: null, ageHours: null, stale: false };
+  }
+  const timestamp = Date.parse(recordTime);
+  if (!Number.isFinite(timestamp)) {
+    return { recordTime, maxAgeHours, ageHours: null, stale: false };
+  }
+  const ageHours = Math.max(0, (now - timestamp) / (60 * 60 * 1000));
+  return { recordTime, maxAgeHours, ageHours, stale: ageHours > maxAgeHours };
+}
+
 async function callVocApi(input = {}) {
   const idOrPath = input.id || input.endpointId;
   const rawPath = input.rawPath || input.proxyPath || (idOrPath ? null : input.path);
@@ -170,12 +191,9 @@ async function callVocApi(input = {}) {
   // 计费优先走 NewAPI(fmode-api)的 sk- token;该 token 鉴权失败(含「未入仓」类报错)时
   // 自动回退平台 sessionToken 重试。注意:以前因为 sk- 直接请求会命中「入仓」错误,这里曾经
   // 直接拒绝 sk- token;现在改为「优先 sk-、鉴权失败再回退 sessionToken」,并对外只给友好提示。
-  const explicitNewApi = readNewApiToken(input);
-  const vocToken = readVocToken(input);
-  // 兼容用户把 sk- 直接填进 VOC_TOKEN 的情况:该槽里若是 sk-,也当作 newapi token。
-  const skFromVocToken = isSkToken(vocToken) ? vocToken : '';
-  const newapiToken = explicitNewApi || skFromVocToken;
-  const sessionToken = isSkToken(vocToken) ? '' : vocToken;
+  const credentials = readCredentialContext(input);
+  const newapiToken = credentials.newApiToken;
+  const sessionToken = credentials.sessionToken;
   const token = newapiToken || sessionToken;
   const fallbackToken = (newapiToken && sessionToken && newapiToken !== sessionToken) ? sessionToken : '';
   if (!token) {
@@ -235,6 +253,9 @@ async function callVocApi(input = {}) {
     : channel === 'ecommerce'
       ? callEcommerceGateway
       : callSocialGateway;
+  const usageContext = contextForTool({ ...input, skillId: input.skillId || 'voc-api-catalog' });
+  const stepContext = createStepContext(usageContext, input.stepId || endpoint?.id || proxyPath);
+  const usageContextFile = writeUsageContext(usageContext, input.outputDir || input.output);
 
   try {
     const result = await callGateway({
@@ -245,8 +266,37 @@ async function callVocApi(input = {}) {
       token,
       fallbackToken,
       baseUrl: input.baseUrl,
-      retries: Number.isFinite(input.retries) ? input.retries : 3
+      retries: Number.isFinite(input.retries) ? input.retries : 3,
+      headers: usageHeaders(stepContext)
     });
+    const freshness = assessFreshness(result.json, endpoint);
+    if (freshness.stale) {
+      const ageLabel = freshness.ageHours.toFixed(1);
+      return {
+        status: 'stale_data',
+        assistantMessage: [
+          `接口 ${proxyPath} 返回了数据,但 recordTime=${freshness.recordTime},快照已滞后约 ${ageLabel} 小时。`,
+          '',
+          `该接口当前按 ${freshness.maxAgeHours} 小时作为可用时效门槛,本次结果只能作为历史参考,不能表述为当前页面数据。`
+        ].join('\n'),
+        summary: {
+          endpoint: endpoint?.id || proxyPath,
+          channel,
+          method,
+          proxyPath,
+          billing: endpoint?.billing || 1,
+          httpStatus: result.httpStatus,
+          authMode: result.authMode,
+          fallbackUsed: Boolean(result.fallbackUsed),
+          ...freshness
+        },
+        data: { result: result.data, raw: result.json },
+        files: [],
+        nextActions: ['优先改用淘宝详情 V7 重试', '稍后重试并重新检查 recordTime'],
+        warnings: [`数据快照超过 ${freshness.maxAgeHours} 小时时效门槛`],
+        errors: []
+      };
+    }
     return okResult({
       assistantMessage: `调用成功:${proxyPath}(${method},${channel} 通道)。`,
       summary: {
@@ -255,9 +305,13 @@ async function callVocApi(input = {}) {
         method,
         proxyPath,
         billing: endpoint?.billing || 1,
-        httpStatus: result.httpStatus
+        httpStatus: result.httpStatus,
+        authMode: result.authMode,
+        fallbackUsed: Boolean(result.fallbackUsed),
+        ...freshness
       },
-      data: { result: result.data, raw: result.json }
+      data: { result: result.data, raw: result.json, usage: stepContext },
+      files: usageContextFile ? [usageContextFile] : []
     });
   } catch (error) {
     const kind = String(error && (error.kind || error.errorKind) || 'upstream');
@@ -266,7 +320,7 @@ async function callVocApi(input = {}) {
 
     if (kind === 'auth') {
       // sk- 与 sessionToken(若有)都未通过校验。不把底层「未入仓」/鉴权报错甩给用户,只给友好提示。
-      const recharge = await buildVocRechargeInfo({ token: sessionToken || token });
+      const recharge = await buildVocRechargeInfo({ sessionToken });
       return {
         status: 'needs_valid_token',
         assistantMessage: [
@@ -276,7 +330,7 @@ async function callVocApi(input = {}) {
           '',
           '若 `sk-` 仍校验不过,再回退以 `r:` 开头的会话 token。',
           '',
-          `(仅当确认是 402 余额不足时才需充值:${recharge.paymentUrl})`
+          '只有接口明确返回 402 时才会提供当前用户充值入口。'
         ].join('\n'),
         summary: { endpoint: endpoint?.id || proxyPath, channel, errorKind: kind, httpStatus, recoverable: true },
         data: { recharge },
@@ -290,15 +344,23 @@ async function callVocApi(input = {}) {
       };
     }
     if (kind === 'billing') {
-      // 402:NewAPI(fmode-api)余额不足,跳转 fmode-api 余额充值弹窗。
-      const fmodeApiRechargeUrl = buildFmodeApiRechargeUrl();
+      const recharge = await buildVocRechargeInfo({ sessionToken });
+      const rechargeStatus = recharge.personalized ? 'needs_recharge' : 'needs_session';
       return {
-        status: 'needs_recharge',
-        assistantMessage: buildFmodeApiRechargeMessage(fmodeApiRechargeUrl, { platformLabel: '社交平台' }),
-        summary: { endpoint: endpoint?.id || proxyPath, channel, errorKind: kind, httpStatus },
-        data: { recharge: { paymentUrl: fmodeApiRechargeUrl, kind: 'fmode-api' } },
+        status: rechargeStatus,
+        assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge, { platformLabel: 'VOC' }),
+        summary: {
+          endpoint: endpoint?.id || proxyPath,
+          channel,
+          errorKind: recharge.personalized ? kind : 'missing_session_for_recharge',
+          billingErrorKind: kind,
+          httpStatus
+        },
+        data: { recharge },
         files: [],
-        nextActions: [`打开充值链接补充余额:${fmodeApiRechargeUrl}`],
+        nextActions: recharge.personalized
+          ? [`打开免登录充值链接:${recharge.balanceUrl}`, '充值到账后使用相同参数继续调用']
+          : ['恢复当前用户 Session Token 后打开个人充值页', '充值到账后使用相同参数继续调用'],
         warnings: [],
         errors: []
       };
@@ -332,6 +394,18 @@ async function callVocApi(input = {}) {
         errors: [{ message: safeMessage, kind, httpStatus }]
       };
     }
+    if (kind === 'unsupported') {
+      return {
+        status: 'not_supported',
+        assistantMessage: `接口 ${proxyPath} 当前不支持该商品或该数据口径(${safeMessage})。`,
+        summary: { endpoint: endpoint?.id || proxyPath, channel, errorKind: kind, httpStatus },
+        data: { message: safeMessage },
+        files: [],
+        nextActions: ['改用淘宝详情 V7 获取基础详情', '不要把该响应作为不完整商品详情继续使用'],
+        warnings: [],
+        errors: []
+      };
+    }
     return {
       status: 'upstream_unstable',
       assistantMessage: [
@@ -352,5 +426,7 @@ async function callVocApi(input = {}) {
 module.exports = {
   searchVocApis,
   getVocApiDoc,
-  callVocApi
+  callVocApi,
+  findRecordTime,
+  assessFreshness
 };

+ 9 - 0
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-billing-query-run.js

@@ -0,0 +1,9 @@
+'use strict';
+
+const { queryBilling } = require('../core/billing-client');
+
+async function queryVocBilling(input = {}) {
+  return queryBilling(input);
+}
+
+module.exports = { queryVocBilling };

+ 89 - 0
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-cost-estimate-run.js

@@ -0,0 +1,89 @@
+'use strict';
+
+const { queryBilling } = require('../core/billing-client');
+const { contextForTool, writeUsageContext } = require('../core/usage-context');
+const { buildWorkflowPlans, flattenPlans } = require('../features/voc-cost/workflow-planner');
+const { buildPlanEstimate, formatEstimateMessage } = require('../features/voc-cost/estimate-report');
+const { errorResult } = require('../core/result-envelope');
+
+async function runVocCostEstimate(input = {}) {
+  const context = contextForTool({ ...input, skillId: input.skillId || 'voc-cost-controller' });
+  const plans = buildWorkflowPlans(input);
+  const flat = flattenPlans(plans);
+  if (!flat.length) return errorResult('请提供 operations 或可识别的 workflowType 与任务规模。', { summary: { errorKind: 'missing_plan' } });
+  const query = await queryBilling({
+    ...input,
+    operations: flat.map((op) => ({ model: op.model, apigId: op.apigId, path: op.path, count: op.count, label: `${op._planId}:${op.label}` })),
+    rechargeBufferCny: input.rechargeBufferCny
+  });
+  if (query.status !== 'ok') return { ...query, data: { ...query.data, context } };
+  const priced = query.data?.estimate?.operations || [];
+  let cursor = 0;
+  const reports = plans.map(plan => {
+    const own = plan.operations.map(op => {
+      const source = priced[cursor++] || {};
+      return { ...op, ...source, path: source.path || op.path, count: source.count || op.count };
+    });
+    return buildPlanEstimate(plan, own, {
+      balance: query.data.balance,
+      budgetLimitCny: input.budgetCny ?? input.budgetLimitCny,
+      actualCostCny: input.actualCostCny,
+      rechargeBufferCny: input.rechargeBufferCny
+    });
+  });
+  const expectedPlan = reports.find(item => item.mode === (input.mode || 'standard')) || reports[0];
+  const balance = query.data.balance || {};
+  const shortages = reports.map(item => ({
+    mode: item.mode,
+    sufficient: item.budget?.status !== 'needs_recharge',
+    ...(item.budget ? {
+      shortfallCny: item.budget.shortfallCny,
+      suggestedRechargeCny: item.budget.suggestedRechargeCny
+    } : {})
+  }));
+  const report = {
+    context,
+    workflowType: input.workflowType || plans[0]?.workflowType || 'voc_custom_workflow',
+    plans: reports,
+    selectedMode: expectedPlan?.mode || null,
+    balance,
+    pricing: query.data.catalog || [],
+    shortages,
+    shortestPlan: reports.slice().sort((a, b) => a.expectedCny - b.expectedCny)[0] || null
+  };
+  const files = [];
+  if (input.outputDir) {
+    const filePath = writeUsageContext(context, input.outputDir);
+    if (filePath) files.push(filePath);
+  }
+  return {
+    status: 'ok',
+    assistantMessage: formatEstimateMessage(report, balance),
+    summary: {
+      usageTraceId: context.usageTraceId,
+      reportId: context.reportId,
+      selectedMode: report.selectedMode,
+      expectedCny: expectedPlan?.expectedCny || 0,
+      highCny: expectedPlan?.highCny || 0,
+      sufficient: query.data.estimate?.sufficient === true,
+      pricingVersion: context.pricingVersion || null
+    },
+    data: report,
+    recharge: query.recharge || null,
+    files,
+    nextActions: query.data.estimate?.sufficient === false ? ['充值到账后使用相同 workflowType 和规模重新查询,再从检查点继续。'] : [],
+    warnings: query.warnings || [],
+    errors: []
+  };
+}
+
+module.exports = { runVocCostEstimate };
+
+if (require.main === module) {
+  runVocCostEstimate(JSON.parse(process.argv[2] || '{}')).then(result => {
+    process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+  }).catch(error => {
+    process.stderr.write(`${error.stack || error}\n`);
+    process.exitCode = 1;
+  });
+}

+ 91 - 0
claude-code/claude-code-voc-intelligence/mcp/src/tools/voc-usage-report-run.js

@@ -0,0 +1,91 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const { queryBilling } = require('../core/billing-client');
+const { contextForTool } = require('../core/usage-context');
+const { errorResult } = require('../core/result-envelope');
+
+function readJson(filePath) {
+  try {
+    return JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return null;
+  }
+}
+
+function extractUsage(source = {}) {
+  const usage = source.usage || source.data?.usage || source.data?.billing || source.billing || source;
+  const rows = Array.isArray(usage.groups) ? usage.groups : Array.isArray(usage.items) ? usage.items : Array.isArray(usage.rows) ? usage.rows : Array.isArray(usage.operations) ? usage.operations : [];
+  const totals = usage.totals || usage.summary || usage;
+  const calls = Number(totals.calls ?? totals.callCount ?? rows.reduce((sum, row) => sum + Number(row.calls ?? row.count ?? 0), 0)) || 0;
+  const billedCalls = Number(totals.billedCalls ?? totals.billed_call_count ?? rows.reduce((sum, row) => sum + Number(row.billedCalls ?? row.billedCount ?? row.count ?? 0), 0)) || 0;
+  const quota = Number(totals.quota ?? totals.totalQuota ?? rows.reduce((sum, row) => sum + Number(row.quota ?? 0), 0)) || 0;
+  const costCny = Number(totals.costCny ?? totals.amountCny ?? totals.actualCostCny ?? rows.reduce((sum, row) => sum + Number(row.costCny ?? row.amountCny ?? row.actualCostCny ?? 0), 0)) || 0;
+  return { calls, billedCalls, quota, costCny: Number(costCny.toFixed(6)), rows, quality: usage.quality || usage.dataQuality || (usage.reconstructed ? 'reconstructed' : 'exact') };
+}
+
+function formatMessage(report) {
+  const total = report.totals || {};
+  const lines = [`实际用量:${total.calls || 0} 次调用,计费 ${total.billedCalls || 0} 次,${total.quota || 0} quota,¥${Number(total.costCny || 0).toFixed(2)}。`];
+  if (report.quality && report.quality !== 'exact') lines.push(`数据质量:${report.quality}。`);
+  const rows = (report.rows || []).slice().sort((a, b) => Number(b.costCny || b.amountCny || 0) - Number(a.costCny || a.amountCny || 0)).slice(0, 10);
+  for (const row of rows) {
+    const endpoint = row.endpointId || row.path || row.apigId || row.model || '未命名接口';
+    lines.push(`${endpoint}:${Number(row.calls ?? row.count ?? 0)} 次,${Number(row.quota || 0)} quota,¥${Number(row.costCny ?? row.amountCny ?? row.actualCostCny ?? 0).toFixed(2)}。`);
+  }
+  return lines.join('\n');
+}
+
+async function runVocUsageReport(input = {}) {
+  const context = contextForTool({ ...input, skillId: input.skillId || 'voc-cost-controller' });
+  const local = input.reportPath || input.usagePath ? readJson(input.reportPath || input.usagePath) : null;
+  let source = local;
+  let query = null;
+  if (!source || input.refresh === true) {
+    query = await queryBilling({
+      ...input,
+      include: ['balance', 'usage'],
+      usageOnly: true,
+      operations: Array.isArray(input.operations) ? input.operations : [],
+      usageTraceId: input.usageTraceId,
+      groupBy: input.groupBy || ['module', 'endpoint', 'usageTraceId']
+    });
+    if (query.status !== 'ok' && !source) return { ...query, data: { ...query.data, context } };
+    source = query?.data || source || {};
+  }
+  const extracted = extractUsage(source || {});
+  if (!extracted.rows.length && extracted.calls === 0 && !query && !local) return errorResult('未找到可汇总的实际用量记录,请提供 usageTraceId/reportId、日期范围或 reportPath。', { summary: { errorKind: 'missing_usage_filter' } });
+  const report = {
+    context,
+    usageTraceId: input.usageTraceId || source.usageTraceId || null,
+    reportId: input.reportId || source.reportId || context.reportId,
+    from: input.from || source.from || null,
+    to: input.to || source.to || null,
+    quality: extracted.quality,
+    totals: { calls: extracted.calls, billedCalls: extracted.billedCalls, quota: extracted.quota, costCny: extracted.costCny },
+    rows: extracted.rows,
+    balance: source.balance || null,
+    pricing: source.catalog || source.pricing || []
+  };
+  return {
+    status: 'ok',
+    assistantMessage: formatMessage(report),
+    summary: { usageTraceId: report.usageTraceId, reportId: report.reportId, calls: extracted.calls, billedCalls: extracted.billedCalls, quota: extracted.quota, costCny: extracted.costCny, quality: report.quality },
+    data: report,
+    recharge: null,
+    files: input.reportPath || input.usagePath ? [path.resolve(input.reportPath || input.usagePath)] : [],
+    nextActions: [],
+    warnings: query?.warnings || [],
+    errors: []
+  };
+}
+
+module.exports = { runVocUsageReport, extractUsage };
+
+if (require.main === module) {
+  runVocUsageReport(JSON.parse(process.argv[2] || '{}')).then(result => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)).catch(error => {
+    process.stderr.write(`${error.stack || error}\n`);
+    process.exitCode = 1;
+  });
+}

+ 266 - 48
claude-code/claude-code-voc-intelligence/mcp/src/tools/xiaohongshu-trend-run.js

@@ -2,18 +2,21 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { readXiaohongshuToken, readNewApiToken } = require('../core/credentials');
+const { readCredentialContext } = require('../core/credentials');
 const { buildSampleDataset } = require('../features/xiaohongshu-trend/sample-data');
 const { collectLiveDataset } = require('../features/xiaohongshu-trend/live-collector');
 const { cacheEvidenceAssets } = require('../features/xiaohongshu-trend/assets');
+const { processMedia, replaceMediaUrls } = require('../features/media/media-pipeline');
 const { buildTrendReport, writeReport } = require('../features/xiaohongshu-trend/report');
 const { trendMemoryPath, readTrendMemory } = require('../features/xiaohongshu-trend/preference-memory');
 const { buildNoSampleFallbackMessage } = require('../core/evidence-quality');
+const { contextForTool, createStepContext, usageHeaders, writeUsageContext } = require('../core/usage-context');
+const { createBatchBudgetGuard, findBudgetIssue, budgetStoppedMessage } = require('../core/batch-budget-guard');
+const { runVocCostEstimate } = require('./voc-cost-estimate-run');
 const {
   buildVocRechargeInfo,
   buildMissingNewApiTokenMessage,
-  buildRechargeRequiredMessage,
-  buildFmodeApiRechargeUrl
+  buildRechargeRequiredMessage
 } = require('../core/payment-links');
 
 function parseArgs(argv) {
@@ -73,6 +76,29 @@ function readJsonIfExists(filePath) {
   return JSON.parse(fs.readFileSync(absolute, 'utf8'));
 }
 
+function readCheckpoint(outputDir, input = {}) {
+  if (!(input.resume || input.resumeFrom || input.checkpointPath)) return null;
+  const file = path.resolve(input.resumeFrom || input.checkpointPath || path.join(outputDir, 'checkpoint.json'));
+  try {
+    const value = JSON.parse(fs.readFileSync(file, 'utf8'));
+    return value && typeof value === 'object' ? value : null;
+  } catch { return null; }
+}
+
+function writeCheckpoint(outputDir, context, dataset, completedKeywords = [], pendingKeywords = []) {
+  const file = path.join(outputDir, 'checkpoint.json');
+  fs.mkdirSync(outputDir, { recursive: true });
+  fs.writeFileSync(file, JSON.stringify({
+    usageTraceId: context.usageTraceId,
+    reportId: context.reportId,
+    completedKeywords,
+    pendingKeywords,
+    dataset,
+    savedAt: new Date().toISOString()
+  }, null, 2));
+  return file;
+}
+
 const AUTH_IDENTITY_RE = /company或用户信息不存在|用户信息不存在|用户不存在|账[号户]信息不存在|未登录|登录失效|Invalid API token|无效的?API?令牌|无效.*token|token.*invalid|未授权|unauthorized|请输入.*token|sessiontoken/i;
 
 function findAuthError(errors = []) {
@@ -91,15 +117,30 @@ function findGatewayError(errors = []) {
     const status = Number(error && error.httpStatus);
     const message = String(error && error.message || '');
     const kind = String(error && (error.kind || error.errorKind) || '');
-    if (kind === 'auth' || kind === 'upstream_search' || AUTH_IDENTITY_RE.test(message)) return false;
+    if (kind === 'auth' || kind === 'permission' || kind === 'budget' || kind === 'upstream_search' || AUTH_IDENTITY_RE.test(message)) return false;
     if (status >= 500) return false;
-    return status === 402 ||
-      kind === 'billing' ||
-      (status === 403 && /没有开通|余额不足|无权限|权限或余额|permission|quota|balance/i.test(message)) ||
-      /没有开通|余额不足|无权限|权限或余额|insufficient|balance|quota|payment/i.test(message);
+    return status === 402 || kind === 'billing';
   });
 }
 
+function findPermissionError(errors = []) {
+  return errors.find(error => (
+    String(error && (error.kind || error.errorKind) || '') === 'permission' ||
+    Number(error && error.httpStatus) === 403
+  ));
+}
+
+function rechargeStatusFor(recharge, partial = false) {
+  if (recharge?.personalized) return partial ? 'partial_needs_recharge' : 'needs_recharge';
+  return partial ? 'partial_needs_session' : 'needs_session';
+}
+
+function rechargeActionFor(recharge) {
+  return recharge?.personalized
+    ? `打开免登录充值链接:${recharge.balanceUrl}`
+    : '恢复当前用户 Session Token 后生成个人充值链接或支付二维码';
+}
+
 function findUpstreamError(errors = []) {
   return errors.find(error => {
     const status = Number(error && error.httpStatus);
@@ -135,16 +176,43 @@ function loadProfile(args) {
   };
 }
 
+async function runCostEstimateSafely(input) {
+  try {
+    return await runVocCostEstimate(input);
+  } catch (error) {
+    return {
+      status: 'upstream_unstable',
+      assistantMessage: `执行前成本测算暂时不可用:${error.message || String(error)}`,
+      summary: { errorKind: 'estimate_failed' },
+      data: {},
+      files: [],
+      nextActions: ['稍后重试成本测算'],
+      warnings: [],
+      errors: []
+    };
+  }
+}
+
 async function runXiaohongshuTrend(input = {}) {
   const collectionMode = input.collectionMode || input['collection-mode'] || 'sample';
   const outputDir = path.resolve(input.output || path.join('outputs', 'claude-code-xiaohongshu-trend', new Date().toISOString().slice(0, 10)));
+  const usageContext = contextForTool({ ...input, skillId: input.skillId || 'xiaohongshu-trend-intelligence', workflowType: input.workflowType || 'xhs_trend_report' });
+  const stepContext = createStepContext(usageContext, input.stepId || 'collect_xiaohongshu');
+  writeUsageContext(usageContext, outputDir);
   const profile = loadProfile(input);
   const memoryPath = trendMemoryPath(input);
+  const checkpoint = readCheckpoint(outputDir, input);
   const memory = readTrendMemory(memoryPath);
   const warnings = [];
   const errors = [];
   const assetFiles = [];
   let dataset;
+  let liveDataset = { completedKeywords: [] };
+  let checkpointFile = null;
+  let batchBudget = null;
+  let budgetIssue = null;
+  let rechargeSessionToken = '';
+  let estimateResult = null;
 
   if (collectionMode === 'sample') {
     dataset = buildSampleDataset(profile);
@@ -152,7 +220,12 @@ async function runXiaohongshuTrend(input = {}) {
   } else if (collectionMode === 'live') {
     // 计费优先走 newapi 的 sk- token(与 voc_api_call 一致,server.fmode.cn/api/voc-social 同一网关
     // 已验证 Bearer sk- 可采集),读不到再回退平台会话 token。不再把 sk- 误判为「类型错误」。
-    const token = readNewApiToken(input) || readXiaohongshuToken(input);
+    const credentials = readCredentialContext(input);
+    rechargeSessionToken = credentials.sessionToken || '';
+    const token = credentials.newApiToken || credentials.sessionToken;
+    const fallbackToken = credentials.newApiToken && credentials.sessionToken
+      ? credentials.sessionToken
+      : '';
     if (!token) {
       const recharge = await buildVocRechargeInfo();
       return {
@@ -166,8 +239,7 @@ async function runXiaohongshuTrend(input = {}) {
         files: [],
         nextActions: [
           '从 ~/.claude/settings.json 读取 env.ANTHROPIC_AUTH_TOKEN(sk- 开头)或用 FMODE_API_KEY/newapiToken 入参重试',
-          '仍读不到时,再回退 r: 开头的会话 token,或确认是否真的 402 余额不足',
-          `确认确实是 fmode-api 余额不足/未开通时,再前往充值:${buildFmodeApiRechargeUrl()}`,
+          '仍读不到时,再恢复当前用户的 Session Token;缺凭证不是余额不足',
           '如果只是录制教学,可以先用 sample 模式演示完整流程'
         ],
         warnings: [],
@@ -175,45 +247,85 @@ async function runXiaohongshuTrend(input = {}) {
       };
     }
 
-    const liveDataset = await collectLiveDataset({
+    const keywordLimit = Math.max(1, Number(input.keywordLimit ?? input['keyword-limit'] ?? 1));
+    const keywordCount = Math.min(profile.keywords.length || 1, keywordLimit);
+    const shouldEstimate = input.preflightEstimate === true || input.preflightEstimate === 'true' ||
+      input.budgetCny != null || input.budgetLimitCny != null || Number(input.accountCount || 0) > 20 || keywordCount > 20;
+    if (shouldEstimate) {
+      estimateResult = await runCostEstimateSafely({
+        ...input,
+        workflowType: input.workflowType || (Number(input.accountCount || 0) > 0 ? 'xhs_account_monitor' : 'xhs_trend_report'),
+        accountCount: input.accountCount || undefined,
+        keywordCount,
+        usageTraceId: usageContext.usageTraceId,
+        reportId: usageContext.reportId,
+        skillId: usageContext.skillId,
+        baseUrl: input.baseUrl || input['base-url'],
+        outputDir
+      });
+      if (estimateResult && estimateResult.status !== 'ok' && estimateResult.assistantMessage) {
+        warnings.push(`执行前成本测算提示:${estimateResult.assistantMessage}`);
+      }
+      if (estimateResult && estimateResult.status !== 'ok' && input.requireEstimate === true) return estimateResult;
+    }
+
+    const estimateInsufficient = Boolean(
+      estimateResult?.summary?.sufficient === false ||
+      estimateResult?.data?.shortages?.some(item => item.sufficient === false)
+    );
+    const batchGuard = createBatchBudgetGuard({
+      input: { ...input, ...(estimateInsufficient ? { enforceBudget: true } : {}) },
+      context: usageContext,
+      operationFactory: ({ keyword }) => [
+        { model: 'voc-social', path: 'search_notes', count: 1, label: `搜索关键词:${keyword}` },
+        { model: 'voc-social', path: 'get_note_info', count: Number(input.notesPerKeyword ?? input['notes-per-keyword'] ?? 1), label: '入选笔记详情' },
+        { model: 'voc-social', path: 'get_note_comments', count: Number(input.notesPerKeyword ?? input['notes-per-keyword'] ?? 1) * Number(input.maxCommentPages ?? input['max-comment-pages'] ?? 1), label: '入选笔记评论' }
+      ]
+    });
+    batchBudget = batchGuard;
+
+    liveDataset = await collectLiveDataset({
       profile,
       token,
+      fallbackToken,
       options: {
         keywordLimit: input.keywordLimit || input['keyword-limit'],
         notesPerKeyword: input.notesPerKeyword || input['notes-per-keyword'],
         maxCommentPages: input.maxCommentPages || input['max-comment-pages'],
+        skipKeywords: checkpoint?.completedKeywords || [],
         sort: input.sort,
         noteType: input.noteType || input['note-type'],
-        baseUrl: input.baseUrl || input['base-url'] || process.env.XIAOHONGSHU_API_BASE_URL
+        baseUrl: input.baseUrl || input['base-url'] || process.env.XIAOHONGSHU_API_BASE_URL,
+        headers: usageHeaders(stepContext),
+        beforeBatch: batchGuard.beforeBatch
       }
     });
     dataset = {
-      notes: liveDataset.notes,
-      comments: liveDataset.comments
+      notes: [...(checkpoint?.dataset?.notes || []), ...liveDataset.notes],
+      comments: [...(checkpoint?.dataset?.comments || []), ...liveDataset.comments]
     };
     warnings.push(...liveDataset.warnings);
     errors.push(...liveDataset.errors);
 
     const collectionIssue = findGatewayError([...errors, ...warnings]);
+    budgetIssue = findBudgetIssue(errors);
     if (collectionIssue) {
-      const recharge = await buildVocRechargeInfo({ token });
+      const recharge = await buildVocRechargeInfo({ sessionToken: credentials.sessionToken });
       if (dataset.notes.length) {
-        const partialCacheAssetsEnabled = input.cacheAssets !== false &&
-          input['cache-assets'] !== false &&
-          String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
-        if (partialCacheAssetsEnabled) {
-          const assetResult = await cacheEvidenceAssets({
-            notes: dataset.notes,
-            outputDir,
-            limit: input.assetLimit ?? input['asset-limit'] ?? 10
-          });
-          warnings.push(...assetResult.warnings);
-          assetFiles.push(...assetResult.files);
-        }
+        const mediaResult = await processMedia(dataset, {
+          outputDir,
+          usageTraceId: usageContext.usageTraceId,
+          platform: 'xiaohongshu',
+          maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+        });
+        dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+        warnings.push(...mediaResult.warnings);
+        assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
         const report = buildTrendReport({ profile, dataset, collectionMode, memory });
         const files = writeReport(outputDir, report);
+        checkpointFile = writeCheckpoint(outputDir, usageContext, dataset, [ ...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || []) ], profile.keywords || []);
         return {
-          status: 'partial_needs_recharge',
+          status: rechargeStatusFor(recharge, true),
           assistantMessage: [
             buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
             '',
@@ -235,9 +347,13 @@ async function runXiaohongshuTrend(input = {}) {
             recharge,
             memoryPath
           },
-          files: [...files, ...assetFiles],
+          usage: usageContext,
+          estimate: estimateResult?.data || estimateResult || null,
+          recharge,
+          checkpoint: checkpointFile,
+          files: [...files, ...assetFiles, checkpointFile],
           nextActions: [
-            `打开充值链接:${recharge.paymentUrl}`,
+            rechargeActionFor(recharge),
             '支付完成后用同一组关键词继续补采',
             '先基于已保存的部分样本做初步问题池,但不要当最终结论'
           ],
@@ -246,7 +362,7 @@ async function runXiaohongshuTrend(input = {}) {
         };
       }
       return {
-        status: 'needs_recharge',
+        status: rechargeStatusFor(recharge),
         assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
         summary: {
           noteCount: dataset.notes.length,
@@ -258,9 +374,10 @@ async function runXiaohongshuTrend(input = {}) {
           profile,
           recharge
         },
+        recharge,
         files: [],
         nextActions: [
-          `打开充值链接:${recharge.paymentUrl}`,
+          rechargeActionFor(recharge),
           '支付完成后重新运行本次小红书 live 采集',
           '如果需要先给客户演示,可以切换 sample 模式继续'
         ],
@@ -270,9 +387,45 @@ async function runXiaohongshuTrend(input = {}) {
     }
 
     if (!dataset.notes.length) {
+      if (budgetIssue) {
+        const recharge = budgetIssue.budget?.status === 'needs_recharge'
+          ? await buildVocRechargeInfo({
+            sessionToken: credentials.sessionToken,
+            shortageCny: budgetIssue.budget.shortfallCny,
+            suggestedRechargeCny: budgetIssue.budget.suggestedRechargeCny
+          })
+          : null;
+        checkpointFile = writeCheckpoint(
+          outputDir,
+          usageContext,
+          dataset,
+          checkpoint?.completedKeywords || [],
+          profile.keywords.filter(keyword => !(checkpoint?.completedKeywords || []).includes(keyword))
+        );
+        return {
+          status: recharge
+            ? rechargeStatusFor(recharge)
+            : 'budget_blocked',
+          assistantMessage: [
+            budgetStoppedMessage(budgetIssue.budget),
+            recharge ? buildRechargeRequiredMessage(recharge.paymentUrl, recharge) : ''
+          ].filter(Boolean).join('\n\n'),
+          summary: { noteCount: 0, commentCount: dataset.comments.length, failedStage: 'budget_guard', failedKeyword: budgetIssue.keyword || '', batchBudget: batchBudget ? batchBudget.snapshot() : null },
+          data: { profile, ...(recharge ? { recharge } : {}), batchBudget: batchBudget ? batchBudget.snapshot() : null },
+          usage: usageContext,
+          estimate: estimateResult?.data || estimateResult || null,
+          ...(recharge ? { recharge } : {}),
+          checkpoint: checkpointFile,
+          files: [checkpointFile],
+          nextActions: recharge
+            ? [rechargeActionFor(recharge), '支付完成后使用相同参数从 checkpoint.json 继续本次小红书 live 采集']
+            : ['缩小采集范围或提高本地预算后,从 checkpoint.json 继续本次小红书 live 采集'],
+          warnings,
+          errors: []
+        };
+      }
       const authError = findAuthError(errors);
       if (authError) {
-        const recharge = await buildVocRechargeInfo({ token });
         return {
           status: 'needs_valid_token',
           assistantMessage: [
@@ -282,7 +435,7 @@ async function runXiaohongshuTrend(input = {}) {
             '',
             '请先确认 ~/.claude/settings.json 的 env.ANTHROPIC_AUTH_TOKEN(sk- 开头、base 指向 fmode)有效;仍失败时再回退 r: 会话 token,或确认是否 402 余额不足。',
             '',
-            `(仅当确认余额不足时才充值:${recharge.paymentUrl})`
+            '只有接口明确返回 402 时才会提供当前用户充值入口。'
           ].join('\n'),
           summary: {
             noteCount: 0,
@@ -291,11 +444,10 @@ async function runXiaohongshuTrend(input = {}) {
             failedStage: authError.stage || 'search_notes',
             failedKeyword: authError.keyword || ''
           },
-          data: { profile, recharge },
+          data: { profile },
           files: [],
           nextActions: [
-            `打开充值/开通链接:${recharge.paymentUrl}`,
-            '重新配置有效 session token 后重试 live 模式',
+            '恢复有效 NewAPI Key 或 Session Token 后重试 live 模式',
             '需要录教学视频时,可先用 sample 模式演示完整多轮校准流程'
           ],
           warnings: [],
@@ -305,9 +457,9 @@ async function runXiaohongshuTrend(input = {}) {
 
       const gatewayError = findGatewayError(errors);
       if (gatewayError) {
-        const recharge = await buildVocRechargeInfo({ token });
+        const recharge = await buildVocRechargeInfo({ sessionToken: credentials.sessionToken });
         return {
-          status: 'needs_recharge',
+          status: rechargeStatusFor(recharge),
           assistantMessage: buildRechargeRequiredMessage(recharge.paymentUrl, recharge),
           summary: {
             noteCount: 0,
@@ -316,9 +468,10 @@ async function runXiaohongshuTrend(input = {}) {
             failedKeyword: gatewayError.keyword || ''
           },
           data: { profile, recharge },
+          recharge,
           files: [],
           nextActions: [
-            `打开充值链接:${recharge.paymentUrl}`,
+            rechargeActionFor(recharge),
             '支付完成后重新运行本次小红书 live 采集',
             '如果需要先给客户演示,可以切换 sample 模式继续'
           ],
@@ -327,6 +480,25 @@ async function runXiaohongshuTrend(input = {}) {
         };
       }
 
+      const permissionError = findPermissionError(errors);
+      if (permissionError) {
+        return {
+          status: 'needs_permission',
+          assistantMessage: '当前账号没有小红书数据接口权限,或账号已被禁用。这不是余额不足,请确认账号和接口权限后重试。',
+          summary: {
+            noteCount: 0,
+            commentCount: dataset.comments.length,
+            failedStage: permissionError.stage || 'search_notes',
+            failedKeyword: permissionError.keyword || ''
+          },
+          data: { profile },
+          files: [],
+          nextActions: ['确认当前账号状态和小红书接口权限后重试'],
+          warnings: [],
+          errors: []
+        };
+      }
+
       const upstreamError = findUpstreamError(errors);
       if (upstreamError) {
         return {
@@ -391,7 +563,24 @@ async function runXiaohongshuTrend(input = {}) {
   const cacheAssetsEnabled = input.cacheAssets !== false &&
     input['cache-assets'] !== false &&
     String(input.cacheAssets || input['cache-assets'] || '').toLowerCase() !== 'false';
-  if (cacheAssetsEnabled) {
+  if (collectionMode === 'live') {
+    const mediaResult = await processMedia(dataset, {
+      outputDir,
+      usageTraceId: usageContext.usageTraceId,
+      platform: 'xiaohongshu',
+      maxItems: input.assetLimit ?? input['asset-limit'] ?? 1000
+    });
+    dataset = replaceMediaUrls(dataset, mediaResult.replacements);
+    warnings.push(...mediaResult.warnings);
+    assetFiles.push(...mediaResult.files, mediaResult.manifestFile);
+    checkpointFile = writeCheckpoint(
+      outputDir,
+      usageContext,
+      dataset,
+      [...(checkpoint?.completedKeywords || []), ...(liveDataset.completedKeywords || [])],
+      []
+    );
+  } else if (cacheAssetsEnabled) {
     const assetResult = await cacheEvidenceAssets({
       notes: dataset.notes,
       outputDir,
@@ -417,17 +606,46 @@ async function runXiaohongshuTrend(input = {}) {
       '再基于客户反馈收敛下一轮趋势判断'
     ];
 
+  const budgetRecharge = budgetIssue?.budget?.status === 'needs_recharge'
+    ? await buildVocRechargeInfo({
+      sessionToken: rechargeSessionToken || input.sessionToken || input.vocToken,
+      shortageCny: budgetIssue.budget.shortfallCny,
+      suggestedRechargeCny: budgetIssue.budget.suggestedRechargeCny
+    })
+    : null;
+  const budgetStatus = budgetIssue
+    ? (budgetIssue.budget?.status === 'needs_recharge'
+      ? (budgetRecharge?.personalized ? 'partial_needs_recharge' : 'partial_needs_session')
+      : 'budget_blocked')
+    : 'ok';
+  const budgetMessage = budgetIssue
+    ? `${budgetStoppedMessage(budgetIssue.budget)}${budgetRecharge ? `\n\n${buildRechargeRequiredMessage(budgetRecharge.paymentUrl, budgetRecharge)}` : ''}\n\n`
+    : '';
+
   return okResult({
-    assistantMessage: report.assistantMessage,
-    summary: report.summary,
+    status: budgetStatus,
+    assistantMessage: `${budgetMessage}${report.assistantMessage}`,
+    summary: { ...report.summary, ...(batchBudget ? { batchBudget: batchBudget.snapshot() } : {}) },
     data: {
       ...report.data,
-      memoryPath
+      memoryPath,
+      usage: usageContext,
+      ...(estimateResult ? { estimate: estimateResult.data || estimateResult } : {}),
+      ...(batchBudget ? { batchBudget: batchBudget.snapshot() } : {})
     },
-    files: [...files, ...assetFiles],
-    nextActions,
+    files: [...files, ...assetFiles, path.join(outputDir, 'usage-context.json'), ...(checkpointFile ? [checkpointFile] : [])],
+    usage: usageContext,
+    ...(estimateResult ? { estimate: estimateResult.data || estimateResult } : {}),
+    ...(budgetRecharge ? { recharge: budgetRecharge } : {}),
+    checkpoint: checkpointFile,
+    nextActions: budgetIssue
+      ? [
+        ...(budgetRecharge?.personalized ? [rechargeActionFor(budgetRecharge), '支付完成后使用相同参数从 checkpoint.json 继续本次小红书 live 采集'] : ['缩小采集范围或提高本地预算后,从 checkpoint.json 继续本次小红书 live 采集']),
+        ...nextActions
+      ]
+      : nextActions,
     warnings,
-    errors
+    errors: []
   });
 }
 

+ 2 - 2
claude-code/claude-code-voc-intelligence/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.32",
+  "version": "0.3.33",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@vocmarket/voc-skill",
-      "version": "0.3.32",
+      "version": "0.3.33",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.29.0",
         "zod": "^4.4.3"

+ 7 - 1
claude-code/claude-code-voc-intelligence/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.32",
+  "version": "0.3.33",
   "description": "Claude Code VOC intelligence skills for Xiaohongshu and Douyin trend reports, live social VOC collection, Fmode image analysis, single-issue deep dives, boss/operator action plans, and multi-turn memory.",
   "type": "commonjs",
   "bin": {
@@ -13,6 +13,11 @@
     "smoke:cross-industry": "node scripts/smoke-cross-industry.js",
     "fmode:image": "node mcp/src/tools/fmode-image-analysis.js",
     "fmode:image:smoke": "node scripts/fmode-image-analysis-smoke.js",
+    "smoke:billing": "node scripts/billing-client-smoke.js",
+    "smoke:budget": "node scripts/smoke-budget-guard.js",
+    "smoke:trend-budget": "node scripts/smoke-trend-budget.js",
+    "smoke:cost": "node scripts/smoke-cost-estimate.js",
+    "smoke:media": "node scripts/smoke-media-pipeline.js",
     "xhs:sample": "node mcp/src/tools/xiaohongshu-trend-run.js --collection-mode sample --profile memory-templates/xiaohongshu-trend-profile.json --output ../outputs/claude-code-xhs-sample --result-prefix XHS_TREND_RESULT",
     "xhs:preference-smoke": "node mcp/src/tools/xiaohongshu-preference-update.js --message \"保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。\" --memory ../outputs/claude-code-xhs-memory-smoke/xiaohongshu-trend-memory.json --result-prefix XHS_PREF_RESULT",
     "xhs:sample-with-memory": "node mcp/src/tools/xiaohongshu-trend-run.js --collection-mode sample --profile memory-templates/xiaohongshu-trend-profile.json --memory ../outputs/claude-code-xhs-memory-smoke/xiaohongshu-trend-memory.json --output ../outputs/claude-code-xhs-memory-smoke/report --result-prefix XHS_TREND_RESULT",
@@ -33,6 +38,7 @@
     "acceptance:installer": "node scripts/installer-acceptance.js"
   },
   "files": [
+    "SKILL.md",
     ".claude-plugin/",
     ".mcp.json",
     "bin/",

+ 4 - 1
claude-code/claude-code-voc-intelligence/scripts/acceptance.js

@@ -11,6 +11,8 @@ function main() {
   fs.mkdirSync(npmCache, { recursive: true });
 
   run('npm', ['run', 'fmode:image:smoke'], ROOT);
+  run('npm', ['run', 'smoke:budget'], ROOT);
+  run('npm', ['run', 'smoke:trend-budget'], ROOT);
   run('npm', ['run', 'smoke:package'], ROOT);
   run('npm', ['run', 'mcp:smoke'], ROOT);
   run('npm', ['run', 'acceptance:installer'], ROOT);
@@ -31,7 +33,8 @@ function main() {
     'voc-speaking-script',
     'voc-competitor-map',
     'voc-business-workflow',
-    'fmode-image-analysis'
+    'fmode-image-analysis',
+    'voc-cost-controller'
   ];
 
   assert(fs.existsSync(mcpFile), 'workspace install should write .mcp.json');

+ 79 - 0
claude-code/claude-code-voc-intelligence/scripts/billing-client-smoke.js

@@ -0,0 +1,79 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { queryBilling } = require('../mcp/src/core/billing-client');
+const { requestPaymentQr } = require('../mcp/src/core/recharge-client');
+
+async function main() {
+  const originalFetch = global.fetch;
+  let billingCalls = 0;
+  global.fetch = async (url, options) => {
+    if (/\/api\/fmode\/billing\/query$/.test(String(url))) {
+      billingCalls += 1;
+      assert.equal(options.method, 'POST');
+      assert.match(options.headers.Authorization, /^Bearer sk-test$/);
+      return new Response(JSON.stringify({
+        code: 200,
+        data: {
+          balance: { availableQuota: 1000 },
+          balanceUrl: billingCalls > 1 ? 'https://app.fmode.cn/dev/studio/balance/?token=r%3Asession-test' : null,
+          estimate: {
+            quota: 2000,
+            amountCny: 1,
+            shortfallQuota: 1000,
+            suggestedRechargeCny: 1.1,
+            sufficient: false,
+            operations: []
+          },
+          catalog: []
+        }
+      }), { status: 200, headers: { 'content-type': 'application/json' } });
+    }
+    assert.match(String(url), /\/parse\/functions\/pay_code2$/);
+    assert.equal(options.method, 'POST');
+    assert.match(options.headers['X-Parse-Session-Token'], /^r:/);
+    return new Response(JSON.stringify({ code_url: ['weixin://pay/query-test'], nonce_str: 'nonce-query' }), { status: 200 });
+  };
+  const result = await queryBilling({
+    newapiToken: 'sk-test',
+    baseUrl: 'https://billing.example.test',
+    operations: [{ model: 'voc-social', count: 1 }]
+  });
+  assert.equal(result.status, 'ok');
+  if (result.recharge) {
+    assert.equal(result.recharge.paymentMode, 'balance_url_and_payment_request');
+    assert.equal(Object.prototype.hasOwnProperty.call(result.recharge.paymentRequest, 'headers'), false);
+    assert.doesNotMatch(JSON.stringify(result), /r:[A-Za-z0-9_-]{8,}/);
+  }
+
+  const qrResult = await queryBilling({
+    newapiToken: 'sk-test',
+    sessionToken: 'r:session-test',
+    baseUrl: 'https://billing.example.test',
+    createPaymentQr: true,
+    operations: [{ model: 'voc-social', count: 1 }]
+  });
+  assert.equal(qrResult.status, 'ok');
+  assert.equal(qrResult.recharge.paymentMode, 'qr_and_balance_url');
+  assert.equal(qrResult.recharge.qrCodeUrl, 'weixin://pay/query-test');
+  assert.equal(qrResult.recharge.paymentRequest.nonceStr, 'nonce-query');
+  assert.doesNotMatch(JSON.stringify(qrResult.recharge.paymentRequest), /r:[A-Za-z0-9_-]{8,}/);
+
+  const qr = await requestPaymentQr({
+    amount: 2,
+    sessionToken: 'r:session-test',
+    fetchImpl: async (_url, options) => {
+      assert.match(options.headers['X-Parse-Session-Token'], /^r:/);
+      return new Response(JSON.stringify({ code_url: ['weixin://pay/test'], nonce_str: 'nonce' }), { status: 200 });
+    }
+  });
+  assert.equal(qr.qrCodeUrl, 'weixin://pay/test');
+  assert.equal(qr.amountCny, 2);
+  global.fetch = originalFetch;
+  console.log('billing-client smoke passed');
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 2 - 1
claude-code/claude-code-voc-intelligence/scripts/fmode-image-analysis-smoke.js

@@ -14,7 +14,8 @@ async function main() {
   assert(noToken.status === 'needs_token', 'missing token should return needs_token');
   assert(Array.isArray(noToken.errors) && noToken.errors.length === 0, 'missing token should keep errors empty');
   assert(!/Bearer\s+/i.test(JSON.stringify(noToken)), 'missing token output should not leak bearer headers');
-  assert(/apig-pay/.test(noToken.assistantMessage), 'missing token output should include recharge link');
+  assert(/settings\.json|Session Token|凭证/.test(noToken.assistantMessage), 'missing token output should explain credential recovery');
+  assert(!/apig-pay|balance=fmodeapi/.test(noToken.assistantMessage), 'missing token output should not show a payment link');
 
   const mock = await startMockFmode();
   try {

+ 57 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-budget-guard.js

@@ -0,0 +1,57 @@
+'use strict';
+
+const assert = require('assert');
+const { createBatchBudgetGuard } = require('../mcp/src/core/batch-budget-guard');
+
+async function main() {
+  const calls = [];
+  const context = { usageTraceId: 'usage_smoke_budget', reportId: 'report_smoke_budget' };
+  const input = {
+    budgetCny: 1,
+    queryBillingImpl: async ({ operations }) => {
+      calls.push(operations);
+      return {
+        status: 'ok',
+        data: {
+          balance: { availableQuota: 100 },
+          estimate: { quota: 1000, amountCny: 1.2, operations },
+          usage: { totals: { costCny: 0.8 } }
+        }
+      };
+    }
+  };
+  const guard = createBatchBudgetGuard({
+    input,
+    context,
+    operationFactory: () => [{ model: 'voc-social', path: 'search_notes', count: 1, priceCnyPerCall: 1.2, quotaPerCall: 1000 }]
+  });
+  const decision = await guard.beforeBatch({ keyword: '预算测试', batchIndex: 0 });
+  assert.strictEqual(decision.allowed, false);
+  assert.strictEqual(decision.status, 'blocked');
+  assert.strictEqual(decision.budgetLimitCny, 1);
+  assert.strictEqual(calls.length, 1);
+  assert.strictEqual(guard.snapshot().keyword, '预算测试');
+
+  const rechargeGuard = createBatchBudgetGuard({
+    input: { budgetCny: 20, queryBillingImpl: async () => ({
+      status: 'ok',
+      data: {
+        balance: { availableQuota: 10 },
+        estimate: { quota: 1000, amountCny: 2, operations: [] },
+        usage: { totals: { costCny: 0 } }
+      }
+    }) },
+    context,
+    operationFactory: () => [{ model: 'voc-social', path: 'search_notes', count: 1 }]
+  });
+  const rechargeDecision = await rechargeGuard.beforeBatch({ keyword: '余额测试' });
+  assert.strictEqual(rechargeDecision.status, 'needs_recharge');
+  assert.strictEqual(rechargeDecision.allowed, false);
+
+  process.stdout.write('budget guard smoke ok\n');
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error}\n`);
+  process.exitCode = 1;
+});

+ 54 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-cost-estimate.js

@@ -0,0 +1,54 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { buildWorkflowPlans } = require('../mcp/src/features/voc-cost/workflow-planner');
+const { checkBudget } = require('../mcp/src/core/budget-guard');
+const { runVocCostEstimate } = require('../mcp/src/tools/voc-cost-estimate-run');
+const { runVocUsageReport } = require('../mcp/src/tools/voc-usage-report-run');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+async function main() {
+  const plans = buildWorkflowPlans({ workflowType: 'xhs_account_monitor', accountCount: 280, unresolvedAccountCount: 105 });
+  assert.equal(plans.length, 3);
+  assert.equal(plans[0].operations.find(item => item.path.includes('search_user')).count, 105);
+  assert.equal(plans[0].operations.find(item => item.path.includes('get_user_note')).count, 280);
+  const trendPlans = buildWorkflowPlans({ workflowType: 'douyin_trend_report', keywordCount: 25, videosPerKeyword: 2, maxCommentPages: 0 });
+  assert.equal(trendPlans.length, 3);
+  assert.equal(trendPlans[0].workflowType, 'douyin_trend_report');
+  assert.equal(trendPlans[0].operations.find(item => item.path.includes('fetch_general_search')).count, 25);
+  assert.equal(trendPlans[0].operations.some(item => item.path.includes('fetch_video_comments')), false);
+  const guard = checkBudget({ estimate: { amountCny: 100, quota: 1000 }, balance: { availableCny: 100 }, budgetLimitCny: 110, nextCostCny: 90 });
+  assert.equal(guard.status, 'warning');
+  const blocked = checkBudget({ estimate: { amountCny: 100 }, budgetLimitCny: 50, nextCostCny: 60 });
+  assert.equal(blocked.status, 'blocked');
+
+  const oldFetch = global.fetch;
+  global.fetch = async (_url, options) => {
+    const body = JSON.parse(options.body);
+    if (Array.isArray(body.include) && body.include.includes('usage')) {
+      return new Response(JSON.stringify({ code: 200, data: { usage: { quality: 'exact', totals: { calls: 3, billedCalls: 2, quota: 120, costCny: 0.24 }, groups: [{ endpoint: 'xhs.search', calls: 3, billedCalls: 2, quota: 120, costCny: 0.24 }] } } }), { status: 200 });
+    }
+    return new Response(JSON.stringify({ code: 200, data: { balance: { availableQuota: 100000 }, catalog: [], estimate: { quota: 100, amountCny: 1, sufficient: true, operations: body.operations.map(item => ({ model: item.model, path: item.path, count: item.count, quotaPerCall: 1, quota: item.count, priceCnyPerCall: 0.01, amountCny: item.count * 0.01 })) }, balanceUrl: null } }), { status: 200 });
+  };
+  const estimate = await runVocCostEstimate({ newapiToken: 'sk-test', baseUrl: 'https://billing.test', workflowType: 'tmall_listing_audit', productCount: 134 });
+  assert.equal(estimate.status, 'ok');
+  assert.equal(estimate.data.plans.length, 3);
+  assert.equal(estimate.data.plans[0].assumptions.shopListPages, 5);
+  assert.doesNotMatch(JSON.stringify(estimate), /sk-test/);
+
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-usage-smoke-'));
+  const usagePath = path.join(tmp, 'usage.json');
+  fs.writeFileSync(usagePath, JSON.stringify({ usageTraceId: 'usage_smoke', usage: { quality: 'exact', totals: { calls: 2, billedCalls: 2, quota: 20, costCny: 0.2 }, items: [{ path: 'xhs.search', calls: 2, quota: 20, costCny: 0.2 }] } }));
+  const usage = await runVocUsageReport({ reportPath: usagePath });
+  assert.equal(usage.status, 'ok');
+  assert.equal(usage.summary.costCny, 0.2);
+  global.fetch = oldFetch;
+  console.log('cost estimate and usage report smoke passed');
+}
+
+main().catch(error => {
+  console.error(error);
+  process.exitCode = 1;
+});

+ 3 - 3
claude-code/claude-code-voc-intelligence/scripts/smoke-mcp.js

@@ -297,8 +297,8 @@ async function main() {
     });
     const taobaoDetailEndpoints = taobaoDetailSearchResult.structuredContent?.data?.endpoints || [];
     const taobaoDetailIds = taobaoDetailEndpoints.map(endpoint => endpoint.id);
-    if (taobaoDetailIds[0] !== 'taobao.get_item_detail_v6') {
-      throw new Error(`Expected current Taobao endpoints with v6 preferred for product details, got: ${taobaoDetailIds.join(', ')}`);
+    if (taobaoDetailIds[0] !== 'taobao.get_item_detail_v7') {
+      throw new Error(`Expected current Taobao endpoints with v7 preferred for fresh product details, got: ${taobaoDetailIds.join(', ')}`);
     }
 
     const taobaoCatalogResult = await client.callTool({
@@ -306,7 +306,7 @@ async function main() {
       arguments: { platform: 'taobao' }
     });
     const taobaoCatalogIds = (taobaoCatalogResult.structuredContent?.data?.endpoints || []).map(endpoint => endpoint.id);
-    for (const id of ['taobao.get_item_detail_v3', 'taobao.get_item_detail_v6', 'taobao.get_shop_item_list_v4']) {
+    for (const id of ['taobao.get_item_detail_v3', 'taobao.get_item_detail_v6', 'taobao.get_item_detail_v7', 'taobao.get_shop_item_list_v4']) {
       if (!taobaoCatalogIds.includes(id)) {
         throw new Error(`Expected current Taobao catalog to include ${id}, got: ${taobaoCatalogIds.join(', ')}`);
       }

+ 51 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-media-pipeline.js

@@ -0,0 +1,51 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { discoverMedia } = require('../mcp/src/features/media/media-discovery');
+const { processMedia, replaceMediaUrls } = require('../mcp/src/features/media/media-pipeline');
+const { manifestPath } = require('../mcp/src/features/media/media-manifest');
+
+async function main() {
+  const image = `data:image/png;base64,${Buffer.from('png-fixture').toString('base64')}`;
+  const video = `data:video/mp4;base64,${Buffer.from('video-fixture').toString('base64')}`;
+  const input = { id: 'note-1', coverUrl: image, media: { videoUrl: video }, text: 'unchanged' };
+  const discovered = discoverMedia(input);
+  assert.equal(discovered.length, 2);
+  const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voc-media-smoke-'));
+  fs.mkdirSync(path.dirname(manifestPath(outputDir)), { recursive: true });
+  fs.writeFileSync(manifestPath(outputDir), JSON.stringify({
+    items: [{
+      mediaId: 'legacy-cloud-item',
+      sourceUrl: 'https://example.invalid/legacy.jpg',
+      localPath: '',
+      cloudUrl: 'https://storage.example.invalid/legacy.jpg',
+      uploadStatus: 'uploaded'
+    }]
+  }));
+  let uploadCalls = 0;
+  const result = await processMedia(input, {
+    outputDir,
+    usageTraceId: 'usage_media_smoke',
+    platform: 'xiaohongshu',
+    storage: { upload: async () => { uploadCalls += 1; } }
+  });
+  assert.equal(result.items.filter(item => item.cacheStatus === 'cached').length, 2);
+  assert.equal(result.items.filter(item => item.uploadStatus === 'disabled_local_only').length, 3);
+  assert.equal(uploadCalls, 0, 'local-only media pipeline must not upload to Storage');
+  assert(fs.existsSync(result.manifestFile));
+  const savedManifest = JSON.parse(fs.readFileSync(result.manifestFile, 'utf8'));
+  const legacyItem = savedManifest.items.find(item => item.mediaId === 'legacy-cloud-item');
+  assert.equal(legacyItem.cloudUrl, null, 'legacy cloud URLs must be removed from the manifest');
+  assert.equal(legacyItem.uploadStatus, 'disabled_local_only');
+  assert.equal(legacyItem.effectiveUrl, '');
+  const replaced = replaceMediaUrls(input, result.replacements);
+  assert.match(replaced.coverUrl, /^assets\/xiaohongshu\/note-1\//);
+  assert.match(replaced.media.videoUrl, /^assets\/xiaohongshu\/(?:note-1|evidence)\//);
+  assert.equal(result.items.some(item => item.cloudUrl), false);
+  console.log('media pipeline smoke passed');
+}
+
+main().catch(error => { console.error(error); process.exitCode = 1; });

+ 84 - 8
claude-code/claude-code-voc-intelligence/scripts/smoke-package.js

@@ -23,6 +23,7 @@ const {
 } = require('../mcp/src/features/douyin-trend/live-collector');
 const { DouyinApi } = require('../mcp/src/providers/douyin-api');
 const { readVocToken } = require('../mcp/src/core/credentials');
+const { callVocApi } = require('../mcp/src/tools/voc-api-catalog-run');
 const { smokeCrossIndustry } = require('./smoke-cross-industry');
 const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
 const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
@@ -123,10 +124,10 @@ async function smokeNoTokenRechargePrompt() {
       output: path.join(OUTPUT_ROOT, 'no-token')
     });
     const body = String(result.assistantMessage || '');
-    const hasRechargeUrl = /balance=fmodeapi/.test(body);
-    const hasNextActionUrl = (result.nextActions || []).some(action => /balance=fmodeapi/.test(String(action)));
-    if (result.status !== 'needs_token' || !hasRechargeUrl || !hasNextActionUrl || (result.errors || []).length) {
-      throw new Error('no-token live path did not return a clean recharge prompt');
+    const pointsToCredentialRecovery = /settings\.json|Session Token|newapiToken/i.test(body)
+      || (result.nextActions || []).some(action => /settings\.json|Session Token|newapiToken/i.test(String(action)));
+    if (result.status !== 'needs_token' || !pointsToCredentialRecovery || /balance=fmodeapi|apig-pay/.test(body) || (result.errors || []).length) {
+      throw new Error('no-token live path did not return a clean credential prompt');
     }
   } finally {
     Object.entries(originalEnv).forEach(([key, value]) => {
@@ -176,10 +177,10 @@ async function smokeDouyinNoTokenRechargePrompt() {
       output: path.join(OUTPUT_ROOT, 'douyin-no-token')
     });
     const body = String(result.assistantMessage || '');
-    const hasRechargeUrl = /balance=fmodeapi/.test(body);
-    const hasNextActionUrl = (result.nextActions || []).some(action => /balance=fmodeapi/.test(String(action)));
-    if (result.status !== 'needs_token' || !hasRechargeUrl || !hasNextActionUrl || (result.errors || []).length) {
-      throw new Error('douyin no-token live path did not return a clean recharge prompt');
+    const pointsToCredentialRecovery = /settings\.json|Session Token|newapiToken/i.test(body)
+      || (result.nextActions || []).some(action => /settings\.json|Session Token|newapiToken/i.test(String(action)));
+    if (result.status !== 'needs_token' || !pointsToCredentialRecovery || /balance=fmodeapi|apig-pay/.test(body) || (result.errors || []).length) {
+      throw new Error('douyin no-token live path did not return a clean credential prompt');
     }
   } finally {
     Object.entries(originalEnv).forEach(([key, value]) => {
@@ -532,6 +533,79 @@ async function smokeBusinessStatusErrors() {
   }
 }
 
+async function smokeTaobaoFreshnessAndBusinessCodes() {
+  const originalFetch = global.fetch;
+  try {
+    let collectAttempts = 0;
+    global.fetch = async () => {
+      collectAttempts += 1;
+      return {
+        ok: true,
+        status: 200,
+        text: async () => JSON.stringify({
+          code: 200,
+          data: { code: 301, data: null, message: 'COLLECT FAILED, SEND REQUEST AGAIN', recordTime: null }
+        })
+      };
+    };
+    const collectFailure = await callVocApi({
+      id: 'taobao.get_item_detail_v7',
+      params: { itemId: 'smoke-item' },
+      newapiToken: 'sk-smoke-token',
+      baseUrl: 'https://example.test/ecommerce',
+      retries: 1
+    });
+    if (collectFailure.status !== 'upstream_unstable' || collectAttempts !== 2 || Number(collectFailure.summary?.httpStatus) !== 301) {
+      throw new Error(`expected Taobao nested code 301 to retry then fail as upstream_unstable, got ${JSON.stringify(collectFailure)}`);
+    }
+
+    global.fetch = async () => ({
+      ok: true,
+      status: 200,
+      text: async () => JSON.stringify({
+        code: 200,
+        data: { code: 202, data: null, message: 'NOT SUPPORTED', recordTime: null }
+      })
+    });
+    const unsupported = await callVocApi({
+      id: 'taobao.get_item_detail_v7',
+      params: { itemId: 'smoke-item' },
+      newapiToken: 'sk-smoke-token',
+      baseUrl: 'https://example.test/ecommerce',
+      retries: 0
+    });
+    if (unsupported.status !== 'not_supported' || Number(unsupported.summary?.httpStatus) !== 202) {
+      throw new Error(`expected Taobao nested code 202 to return not_supported, got ${JSON.stringify(unsupported)}`);
+    }
+
+    global.fetch = async () => ({
+      ok: true,
+      status: 200,
+      text: async () => JSON.stringify({
+        code: 200,
+        data: {
+          code: 0,
+          data: { subject: 'stale smoke item', price: 10, skuVoList: [] },
+          message: null,
+          recordTime: '2020-01-01T00:00:00'
+        }
+      })
+    });
+    const stale = await callVocApi({
+      id: 'taobao.get_item_detail_v7',
+      params: { itemId: 'smoke-item' },
+      newapiToken: 'sk-smoke-token',
+      baseUrl: 'https://example.test/ecommerce',
+      retries: 0
+    });
+    if (stale.status !== 'stale_data' || !stale.summary?.stale || Number(stale.summary?.maxAgeHours) !== 48) {
+      throw new Error(`expected old Taobao recordTime to return stale_data, got ${JSON.stringify(stale)}`);
+    }
+  } finally {
+    global.fetch = originalFetch;
+  }
+}
+
 async function smokeDouyinSearchInputErrors() {
   const normalized = normalizeSearchKeyword('家居定制18元22元');
   if (normalized !== '家居定制 18元 22元') {
@@ -903,6 +977,7 @@ async function main() {
   smokeAncestorEnvLocalToken();
   await smokeIssuePoolReportEvidenceFiltering();
   await smokeBusinessStatusErrors();
+  await smokeTaobaoFreshnessAndBusinessCodes();
   await smokeDouyinSearchInputErrors();
   await smokeCrossIndustry({ root: ROOT });
 
@@ -960,6 +1035,7 @@ async function main() {
       'VOC business workflow',
       'douyin no-token recharge prompt',
       'business-status recharge guard',
+      'Taobao V7 freshness and nested business-code guard',
       'douyin search input error guard',
       'cross-industry sample smoke',
       'MCP tools'

+ 133 - 0
claude-code/claude-code-voc-intelligence/scripts/smoke-trend-budget.js

@@ -0,0 +1,133 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { runXiaohongshuTrend } = require('../mcp/src/tools/xiaohongshu-trend-run');
+const { runDouyinTrend } = require('../mcp/src/tools/douyin-trend-run');
+
+function jsonResponse(payload, status = 200) {
+  const text = JSON.stringify(payload);
+  return {
+    ok: status >= 200 && status < 300,
+    status,
+    json: async () => payload,
+    text: async () => text
+  };
+}
+
+async function verifyRunner(label, runner, outputName) {
+  const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-budget-`));
+  const fetchCalls = [];
+  const oldFetch = global.fetch;
+  global.fetch = async url => {
+    fetchCalls.push(String(url));
+    if (!String(url).endsWith('/api/fmode/billing/query')) {
+      throw new Error(`${label} should stop before gateway call: ${url}`);
+    }
+    return jsonResponse({
+      code: 200,
+      data: {
+        balance: { availableQuota: 0 },
+        estimate: {
+          quota: 100,
+          amountCny: 2,
+          sufficient: false,
+          shortfallCny: 2,
+          suggestedRechargeCny: 5,
+          operations: []
+        },
+        usage: { totals: { costCny: 0 } }
+      }
+    });
+  };
+
+  const baseInput = {
+    collectionMode: 'live',
+    keywords: ['预算关键词一', '预算关键词二'],
+    keywordLimit: 2,
+    enforceBudget: true,
+    newapiToken: 'sk-test',
+    sessionToken: 'r:smoke-session-token',
+    baseUrl: 'https://billing.test',
+    output
+  };
+
+  try {
+    const first = await runner(baseInput);
+    assert.equal(first.status, 'needs_recharge', `${label} should expose recharge status`);
+    assert.ok(first.recharge?.balanceUrl?.includes('balance'), `${label} should return tokenized Balance URL`);
+    assert.ok(first.checkpoint && fs.existsSync(first.checkpoint), `${label} should write a checkpoint before the first gateway call`);
+    const checkpoint = JSON.parse(fs.readFileSync(first.checkpoint, 'utf8'));
+    assert.deepEqual(checkpoint.completedKeywords, [], `${label} should have no completed keywords after first-batch block`);
+    assert.deepEqual(checkpoint.pendingKeywords, baseInput.keywords, `${label} should persist pending keywords`);
+    assert.equal(fetchCalls.filter(url => url.endsWith('/api/fmode/billing/query')).length, 1);
+
+    checkpoint.completedKeywords = [baseInput.keywords[0]];
+    checkpoint.pendingKeywords = [baseInput.keywords[1]];
+    fs.writeFileSync(first.checkpoint, JSON.stringify(checkpoint, null, 2));
+    fetchCalls.length = 0;
+    const resumed = await runner({ ...baseInput, resume: true, checkpointPath: first.checkpoint });
+    assert.equal(resumed.status, 'needs_recharge', `${label} resume should remain blocked while balance is empty`);
+    assert.equal(resumed.summary.batchBudget.keyword, baseInput.keywords[1], `${label} resume should skip completed keywords and inspect the next pending keyword`);
+    assert.equal(fetchCalls.some(url => !url.endsWith('/api/fmode/billing/query')), false);
+  } finally {
+    global.fetch = oldFetch;
+  }
+}
+
+async function verifyPreflight(label, runner, outputName, searchPayload) {
+  const output = fs.mkdtempSync(path.join(os.tmpdir(), `voc-${outputName}-estimate-`));
+  const oldFetch = global.fetch;
+  let billingCalls = 0;
+  global.fetch = async url => {
+    if (String(url).endsWith('/api/fmode/billing/query')) {
+      billingCalls += 1;
+      return jsonResponse({
+        code: 200,
+        data: {
+          balance: { availableQuota: 100000 },
+          catalog: [],
+          estimate: {
+            quota: 10,
+            amountCny: 0.01,
+            sufficient: true,
+            operations: []
+          }
+        }
+      });
+    }
+    return jsonResponse(searchPayload);
+  };
+  try {
+    const result = await runner({
+      collectionMode: 'live',
+      keywords: ['预估回归'],
+      keywordLimit: 1,
+      maxCommentPages: 0,
+      preflightEstimate: true,
+      newapiToken: 'sk-test',
+      baseUrl: 'https://billing.test',
+      output
+    });
+    assert.equal(result.status, 'ok', `${label} preflight run should complete with fixture data`);
+    assert.ok(result.estimate?.plans?.length, `${label} should expose the Agent cost estimate`);
+    assert.equal(billingCalls, 1, `${label} preflight should use the single billing query`);
+  } finally {
+    global.fetch = oldFetch;
+  }
+}
+
+async function main() {
+  await verifyRunner('xiaohongshu', runXiaohongshuTrend, 'xhs');
+  await verifyRunner('douyin', runDouyinTrend, 'douyin');
+  await verifyPreflight('xiaohongshu', runXiaohongshuTrend, 'xhs', { items: [{ note: { id: 'xhs-fixture-1', title: '预估笔记' } }] });
+  await verifyPreflight('douyin', runDouyinTrend, 'douyin', { business_data: [{ aweme_info: { aweme_id: 'douyin-fixture-1', desc: '预估视频' } }] });
+  process.stdout.write('trend budget and checkpoint smoke ok\n');
+}
+
+main().catch(error => {
+  process.stderr.write(`${error.stack || error}\n`);
+  process.exitCode = 1;
+});

+ 10 - 5
claude-code/claude-code-voc-intelligence/skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.32",
+  "version": "0.3.33",
   "capabilities": {
     "summary": "三类情报源同源接入:①社媒内容 ②国内电商+创作者情报 ③海外选品。统一 search→doc→call、鉴权计费一致。",
     "channels": [
@@ -28,7 +28,7 @@
         "key": "ecommerce",
         "label": "国内电商 + 创作者情报",
         "platforms": 32,
-        "apis": 288,
+        "apis": 289,
         "examples": [
           "京东",
           "淘宝天猫",
@@ -53,7 +53,7 @@
     ],
     "totals": {
       "platforms": 51,
-      "apis": 1343
+      "apis": 1344
     }
   },
   "description": "Claude Code native VOC intelligence package for cross-industry social VOC workflows. Includes Xiaohongshu and Douyin trend intelligence, live token checks, evidence-card reports, single-issue VOC deep dives, boss/operator action plans, and scoped multi-turn memory.",
@@ -69,7 +69,8 @@
     "voc-competitor-map",
     "voc-business-workflow",
     "fmode-image-analysis",
-    "voc-api-catalog"
+    "voc-api-catalog",
+    "voc-cost-controller"
   ],
   "mcpTools": [
     "fmode_image_analysis",
@@ -87,11 +88,15 @@
     "voc_douyin_preference_update",
     "voc_api_search",
     "voc_api_doc",
-    "voc_api_call"
+    "voc_api_call",
+    "voc_billing_query",
+    "voc_cost_estimate",
+    "voc_usage_report"
   ],
   "apiCatalog": "mcp/catalog/voc-social-endpoints.json",
   "entrySkill": "xiaohongshu-trend-intelligence",
   "capabilityMap": "docs/capability-map.md",
+  "taobaoV7Doc": "docs/taobao-item-detail-v7.md",
   "bossPromptTemplates": "docs/boss-prompt-templates.md",
   "liveAcceptanceChecklist": "docs/live-manual-acceptance-checklist.md",
   "troubleshootingPlaybook": "docs/claude-code-troubleshooting-playbook.md",

+ 7 - 1
claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/SKILL.md

@@ -74,9 +74,15 @@ node "<插件目录>/mcp/src/tools/douyin-trend-run.js" --collection-mode live -
 ## 充值 / 计费口径(简版;完整对照见 voc-api-catalog 技能的「错误码速查」references/error-codes.md)
 
 - 缺 token(`needs_token`)= 可恢复、不是没钱:先自救读 `~/.claude/settings.json` 的 `sk-` fmode key(或 `~/.fmode/config.json` / `~/.claude/voc-credentials.json`),用 `FMODE_API_KEY=sk-…` 重试,不要劝充值。
-- 只有真 `402 余额不足` 才充值,入口 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`(NewAPI/fmode-api 计费);`401`=token、`403`=权限,都不充值。`apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF` 仅限 `r:` 会话 token 余额补充
+- 只有真 `402 余额不足` 才充值;透传工具生成的 Tokenized Balance 链接或二维码支付结果。Balance 链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`;`401`=token、`403`=权限,都不充值
 - 实际充值/自救链接以工具返回的 `assistantMessage` / `nextActions` 为准,不要写死。
 
+## 媒体缓存规则
+
+- 真实采集响应中的图片、视频、音频、封面和详情素材,统一先下载到本次输出目录的 `assets/` 并写入 `media-manifest.json`。
+- 报告只引用本地缓存路径;本技能包不调用线上 Storage、不申请预签名上传地址,也不把媒体上传到服务器。
+- 媒体下载失败时保留失败状态和说明,不回退为上游临时热链。
+
 ## 参考资料(按需展开阅读)
 
 - `references/user-workflow.md`:推荐工作流——先判断样例演示 vs 真实采集,再决定调用方式。

+ 1 - 1
claude-code/claude-code-voc-intelligence/skills/douyin-trend-intelligence/references/live-mode.md

@@ -3,7 +3,7 @@
 ## 默认规则
 
 - 样本量保持小
-- 计费/充值口径走简版:缺 token 先自救(读 `~/.claude/settings.json` 的 `sk-` fmode key 重试),不要直接甩充值链接、不要暴露 401/403;只有真 `402` 才给充值链接 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`。**完整口径(401/402/403 对照、回退、apig-pay `r:` 补充链)见 voc-api-catalog 技能的「错误码速查」references/error-codes.md**
+- 计费/充值口径走简版:缺 token 先恢复凭证,不直接甩充值链接、不要暴露 401/403;只有真 `402` 才透传工具生成的 Tokenized Balance 链接或二维码。**完整口径见 voc-api-catalog 技能的「错误码速查」references/error-codes.md**
 - 用户只需要看到“怎么继续”
 
 ## 推荐默认参数

+ 1 - 1
claude-code/claude-code-voc-intelligence/skills/fmode-image-analysis/SKILL.md

@@ -90,7 +90,7 @@ Friendly states (aligned with the fmode-api billing 口径 — self-rescue first
 
 - Missing token (`needs_token`): this is not a balance problem — guide self-rescue first. Read the `sk-` fmode key from `~/.claude/settings.json` (`env.ANTHROPIC_AUTH_TOKEN`), `~/.fmode/config.json`, or `~/.claude/voc-credentials.json`. Do NOT push a recharge link here.
 - Invalid token: return `needs_valid_token` and ask for a valid platform token.
-- Quota exhausted / real 402: return `needs_recharge` with the fmode-api recharge link `https://app.fmode.cn/dev/studio/?balance=fmodeapi` and `errors=[]`.
+- Quota exhausted / real 402: return `needs_recharge` with the runtime-generated Tokenized Balance link or QR payment result and `errors=[]`.
 - Success: return `ok` with the analysis in chat.
 
 Do not expose raw upstream errors, `Authorization`, bearer tokens, session tokens, stack traces, or model keys.

+ 16 - 5
claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/SKILL.md

@@ -66,16 +66,20 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
    - 能力与性价比的客观评估:抖音 `douyin.xingtu.cost_performance_analysis` / `conversion_analysis`;小红书 `xiaohongshu.pgy.cost_effectiveness_analysis`。
 3. 把这些创作者侧事实**喂给下游 VOC 技能**(voc-competitor-map 做竞对达人布局、voc-content-plan / voc-speaking-script 做选题与脚本、voc-issue-pool 补充创作者 / 受众视角),形成洞察结论,而非「该投谁」的采买清单。
 
-> 提示:`voc_api_search` 用「星图 创作者」「蒲公英 画像」「受众分布」「性价比」等词即可定位创作者接口,用「京东 商品」「淘宝 评论」「1688 搜索」「亚马逊 选品」「ABA 关键词」等词定位电商/选品接口;`voc_api_doc` 会在请求行标注通道(ecommerce / overseas)并列出参数。当前清单 ecommerce 通道已登记约 288 个接口(电商商品 + 创作者 + 内容平台)、overseas 通道 37 个(亚马逊选品),未登记的可直接用 `voc_api_call` 传 `rawPath` + 对应 `channel` 调用。
+> 提示:`voc_api_search` 用「星图 创作者」「蒲公英 画像」「受众分布」「性价比」等词即可定位创作者接口,用「京东 商品」「淘宝 评论」「1688 搜索」「亚马逊 选品」「ABA 关键词」等词定位电商/选品接口;`voc_api_doc` 会在请求行标注通道(ecommerce / overseas)并列出参数。当前清单 ecommerce 通道已登记约 289 个接口(电商商品 + 创作者 + 内容平台)、overseas 通道 37 个(亚马逊选品),未登记的可直接用 `voc_api_call` 传 `rawPath` + 对应 `channel` 调用。
 
 ## 淘宝/天猫版本选择与时效
 
-- 查商品详情、SKU 标价与促销价:优先 `taobao.get_item_detail_v6`;查券后价/优惠后价使用 `taobao.get_item_detail_v4`。价格必须标注是标价、促销价、活动价还是券后价,不要压成单一「真实价格」。
+完整的 V7 请求示例、字段表、异常处理和版本职责见 `../../docs/taobao-item-detail-v7.md`。
+
+- 需要 48 小时内的当前标题、基础价格、SKU 和店铺信息:优先 `taobao.get_item_detail_v7`。V7 是实时发起采集的主接口,但 `monthSold`、`baseAttrList` 可能为空,不能单独承担页面销量、完整属性或券后价。
+- V7 返回业务码 `301` 表示采集失败,工具会自动重试;连续失败后按 `upstream_unstable` 返回。业务码 `202` 表示当前商品/口径不支持,按 `not_supported` 返回,均不能当作成功详情使用。
+- 查较完整的商品属性、SKU 标价与促销价可补充使用 `taobao.get_item_detail_v6`;查券后价/优惠后价补充使用 `taobao.get_item_detail_v4`。价格必须标注是标价、促销价、活动价还是券后价,不要压成单一「真实价格」。
 - 查商品页当前展示的「已售/付款人数」:使用 `taobao.get_item_detail_v3` 的 `data.sellCount`。V3 不返回价格,价格与页面销量需分别调用 V6/V3。
 - `taobao.get_item_detail_v1` 仅用于兼容旧流程;`v2` 直接调用不会发起商品采集。`taobao.get_item_sale_v1` 和 `taobao.get_shop_item_list_v3` 已被上游标记弃用。
 - V9 的 `num` 是该版本销售量级信号,`totalCount` 是评价总量,两者都不是商品页当前展示付款人数。V9 `attribute` 可能是独立旧属性快照;价格和“上市时间”等关键属性优先 V6,页面销量优先 V3。
-- 返回里的 `recordTime` 是上游数据采集时间,不是本次 API 请求时间。每个版本有独立数据源/快照,必须用「当前时间 - recordTime」计算滞后并在结果中明示。
-- 上游文档未给出固定刷新 SLA,不要将返回值表述为「请求时实时数据」,也不要将旧版 `orderPayUV` 与商品页当前展示口径直接等同。需要页面口径时以 V3 `sellCount` 为准
+- 返回里的 `recordTime` 是上游数据采集时间,不是本次 API 请求时间。每个版本有独立数据源/快照,必须用「当前时间 - recordTime」计算滞后并在结果中明示;V3/V4/V6/V7 超过 48 小时会返回 `stale_data`,只作历史参考
+- 上游文档未给出固定刷新 SLA,不要将返回值表述为「请求时实时数据」,也不要将旧版 `orderPayUV` 与商品页当前展示口径直接等同。V3 `sellCount` 也必须先通过 48 小时时效门禁,超时后不能再称为页面当前销量
 - 实际结算价还会受 SKU、账号会员价、地区、店铺券、平台补贴和活动时间影响。API 返回的是对应口径快照,不等于任意账号在结算页的最终应付价。
 - 店铺商品列表的新调用优先 `taobao.get_shop_item_list_v4`,入参为 `sellerId` + 可选 `page`。
 
@@ -109,9 +113,16 @@ VOC 后端是一个**通用转发网关**(`https://server.fmode.cn/api/voc-soc
 
 - `ok`:调用成功,`data.result` 是上游返回数据。
 - `needs_token`:没读到任何 token(**可恢复,不是没钱、不是功能用不了**)——优先去 `~/.claude/settings.json` 取 `env.ANTHROPIC_AUTH_TOKEN`(`sk-`)用 `FMODE_API_KEY=sk-…` 传入重试。`needs_valid_token`:token 缺失/失效/无效(HTTP 401)——**不是没钱**,提示配置有效 `sk-`(或回退 `r:`)token。
-- `needs_recharge`:**仅 HTTP 402(余额不足)**,提示充值,跳 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`
+- `needs_recharge`:**仅 HTTP 402(余额不足)**,透传工具生成的 Tokenized Balance 链接或二维码支付结果;Balance 链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`。需要直接出二维码时,调用 `voc_billing_query` 时传 `createPaymentQr=true`,由技能包复用现有 `pay_code2` 链路
 - `needs_permission`:**HTTP 403(账号禁用/无权限)**,**≠ 余额不足**,不引导充值,提示联系服务方确认权限。
 - `needs_input`:必填参数缺失或参数错误(不是类目/余额问题)。
+- `stale_data`:接口有返回,但 `recordTime` 已超过该接口的时效门槛;只能作为历史参考。
+- `not_supported`:业务码 202,当前商品或数据口径不受该接口支持。
 - `upstream_unstable`:上游接口报错或波动(5xx / fetch failed),稍后重试。
 
 > ⚠️ 不要把 **403/401** 一律说成「余额不足」。只有 **402** 才是真的没钱要充值;403 是权限、401 是 token 问题。
+
+## 媒体缓存规则
+
+- 任何通过本技能采集到的图片、视频、音频或封面,交给统一媒体流水线下载到任务本地 `assets/` 并写入清单。
+- 报告和标准化结果只使用本地媒体路径;本技能包不执行线上 Storage 上传,也不请求预签名上传地址。

+ 5 - 8
claude-code/claude-code-voc-intelligence/skills/voc-api-catalog/references/error-codes.md

@@ -14,9 +14,8 @@
 |------|------------------|-----------|-------------|----------------------|----------------|
 | 200 | —(`{"code":200,"data":{...}}`) | — | `ok` | 正常返回 | 直接用 `data.result` |
 | 401 | `Invalid NewAPI token` / `Missing NewAPI token` / `请输入API_KEY或用户sessionToken` / `当前用户不存在,无使用权限` | `auth` | `needs_valid_token`(无任何 token 时 `needs_token`) | 「token 缺失/失效/类型不对,**不是没钱、也不是关键词/类目问题,更不是功能用不了**」 | **先**从 `~/.claude/settings.json` 取 `env.ANTHROPIC_AUTH_TOKEN`(`sk-`),用 `FMODE_API_KEY=sk-…` 传入重试;不行再回退 `r:` sessionToken |
-| 402 | `余额不足` / `额度不足` | `billing` | `needs_recharge` | 「余额不足,请充值」 | 给充值链接 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`(自动打开余额弹窗) |
-| 403 | `用户禁用` / `无权限` / `permission`(**不含**余额/开通关键词) | `permission` | `needs_permission` | 「账号被禁用或没有该接口权限,**≠ 余额不足**」 | 联系服务方确认账号状态/接口权限,**不要**引导充值 |
-| 403 | 含 `未开通` / `没开通` / `余额` / `额度` / `insufficient` / `balance` | `billing` | `needs_recharge` | 「需开通/充值后使用」 | 给充值/开通链接 |
+| 402 | `余额不足` / `额度不足` | `billing` | `needs_recharge` | 「余额不足,请充值」 | 透传工具生成的 Tokenized Balance 链接或二维码支付结果 |
+| 403 | `用户禁用` / `无权限` / `permission` / `未开通` / `没开通` / `余额` / `额度` / `insufficient` / `balance` | `permission` | `needs_permission` | 「账号被禁用或没有该接口权限,**≠ 余额不足**」 | 联系服务方确认账号状态/接口权限,**不要**引导充值;只有 HTTP 402 才走充值 |
 | 400 / 422 | `参数` / `入参` / `keyword` / `bad request` | `request` | `needs_input` | 「参数有误,**不是没数据/不是类目不支持**」 | 用 `voc_api_doc` 核对参数后重试 |
 | 5xx / 连接失败 | `fetch failed` / 超时 / `500` | `upstream` | `upstream_unstable` | 「上游接口波动」 | 稍后重试,不要误报成关键词/余额问题 |
 
@@ -58,9 +57,7 @@ HTTP 403  {"code":403,"mess":"用户禁用或无权限"}
 2. 若 `sk-` token **鉴权失败(kind=auth / 401,含「未入仓」类报错)** 且存在平台 `r:` sessionToken,**自动回退** sessionToken 重试一次。这正是规避「用户拿 token 请求时遇到入仓错误」的方式——不再因为 token 是 `sk-` 就直接拒绝。
 3. **402(余额不足)/ 403(无权限)不回退**——它们是终态,按上表处理。
 
-> **充值入口分两条链(按实际走哪条计费而定)**:
-> - `sk-` NewAPI / fmode-api 计费链(默认,绝大多数缺额场景)→ `https://app.fmode.cn/dev/studio/?balance=fmodeapi`
-> - `r:` 会话 token 链(仅当走 `r:` sessionToken 回退路径、该 token 余额不足时)→ `https://app.fmode.cn/dev/apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF`
+> **充值入口**:运行时根据当前 Session Token 生成 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`;Agent 也可按报价结果调用现有 `pay_code2` 链路返回二维码。不要固定旧 APIG URL。
 >
 > 运行时以工具返回的 `assistantMessage` / `nextActions` 链接为准转述,不要在话术里写死某一条。
 4. 底层「未入仓」/鉴权原始报错**不甩给用户**,对外只给上表的友好提示。
@@ -72,10 +69,10 @@ HTTP 403  {"code":403,"mess":"用户禁用或无权限"}
 1. 文案含「用户/账号信息不存在、未登录、登录失效、请输入…token、sessiontoken」→ `auth`
 2. `status===401` 或文案含「invalid token / 无效token / 未授权 / unauthorized」→ `auth`
 3. `status===402` 或文案含「余额不足 / 额度不足 / 未开通 / 开通…权限 / insufficient / balance / quota / payment / 充值」→ `billing`
-4. `status===403` **且**含余额/开通类关键词 → `billing`
+4. `status===403` → `permission`(即使报文包含余额/开通词,也不把权限错误改判为余额不足)
 5. 文案含「参数 / 入参 / keyword / 关键词 / bad request」或 `status∈{400,422}` → `request`
 6. `status>=500` → `upstream`
 7. `status===403` 或文案含「permission / 权限 / 无权限」→ `permission`
 8. 其它 → `upstream`
 
-> 注意第 4 与第 7 条的区别:**带「余额/开通」关键词的 403 才当充值**;**纯权限 403 一律 `permission`,不充值**
+> 注意:HTTP 403 统一按权限/账号状态处理,不生成充值入口;只有 HTTP 402 或服务端明确返回 402 才进入 `needs_recharge`

+ 4 - 0
claude-code/claude-code-voc-intelligence/skills/voc-business-workflow/SKILL.md

@@ -21,6 +21,10 @@ description: Run the full VOC business workflow from natural boss language: coll
 
 运行 `voc_business_workflow_run`,不要只给分析建议。
 
+当任务包含较大账号/商品/关键词批次、用户给出预算,或明确询问费用时,先在同一次调用中设置 `preflightEstimate=true`(必要时设置 `requireEstimate=true`)。报价仍由 Agent 根据当前价目和调用计划计算,实际调用继续逐次扣 NewAPI 通用余额,不创建任务预付款。
+
+工具返回的 `usage`、`estimate`、`recharge`、`checkpoint` 和 `files` 必须原样保留。若中途余额不足,直接转述带 Token 的 Balance 链接或二维码提示;支付后使用相同 `usageTraceId` 与 `resume/checkpointPath` 继续,已完成关键词不重复采集。
+
 第一轮默认小规模运行。没有明确要求真实采集时用 `sample`;用户说“真实采集 / live / 抓真实数据”时用 `live`。
 
 如果 live 采集返回 `needs_token`、`needs_recharge`、`needs_valid_token` 或 `no_live_samples`,把工具返回的 `assistantMessage` 清楚发给用户,不要暴露底层 403 或上游报错。

+ 16 - 0
claude-code/claude-code-voc-intelligence/skills/voc-cost-controller/SKILL.md

@@ -0,0 +1,16 @@
+---
+name: voc-cost-controller
+description: VOC 复杂采集任务的执行前成本估算、预算检查和执行后用量汇总。
+---
+
+# VOC 成本控制
+
+对账号、商品或关键词规模较大的任务,先调用 `voc_cost_estimate`。Agent 根据返回的接口计划、当前生效价目和余额向用户说明经济版、标准版、完整版的预期成本区间,不把估算当作已消费。
+
+执行过程中使用同一 `usageTraceId`,每批次前调用预算守卫:本地预算达到 80% 发出提示,超过硬上限停止新增采集并保存检查点。NewAPI 仍按每次实际调用实时扣费,不创建任务级支付或冻结余额。
+
+VOC 数据余额不足时,保留已完成结果和检查点,返回差额、建议充值金额、Token 免登录 Balance 链接或现有二维码支付结果。到账后重新查询余额并从检查点继续;Agent 模型自身 402 由宿主运行时处理。
+
+任务完成后调用 `voc_usage_report`,按追踪标识或日期范围展示调用次数、计费次数、quota、用户扣费和接口拆分。大模型费用与 VOC 数据费用分开;没有精确追踪时标记为 `reconstructed` 或 `mixed`。
+
+价格唯一来源是服务端 billing query。目录中的 `billing` 字段不能被当作人民币单价。

+ 19 - 0
claude-code/claude-code-voc-intelligence/skills/voc-cost-controller/references/workflow-cost-models.md

@@ -0,0 +1,19 @@
+# 首批成本模型
+
+## 小红书账号监听
+
+- 账号解析:`S * P(xiaohongshu.search_user_v2)`
+- 账号笔记:`A * N * P(xiaohongshu.get_user_note_list_v4)`
+- 笔记详情:`A * R * P(xiaohongshu.get_note_detail_vN)`
+- 评论:`A * R * K * P(xiaohongshu.get_note_comment_vN)`
+
+经济版只采一页笔记和必要账号解析;标准版按需采详情和异常样本评论;完整版覆盖多页、详情、评论和画像。
+
+## 天猫 Listing 诊断
+
+- 店铺清单:`G * P(taobao.get_shop_item_list_v4)`
+- 轻量商品:`A * P(最小可用详情接口)`
+- 代表/完整商品:`S * P(taobao.get_item_detail_v4)`、`S * P(taobao.get_item_detail_v3)`
+- 评价:`S * K * P(taobao.get_item_comment_v3)`
+
+接口单价、quota 和版本始终从 billing query 返回值读取。每个步骤可附 `valueScore`、`required` 与 `fallback`,用于成本价值排序;是否删除字段必须说明信息损失。

+ 7 - 1
claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/SKILL.md

@@ -57,9 +57,15 @@ node "<插件目录>/mcp/src/tools/xiaohongshu-trend-run.js" --collection-mode l
 ## 充值 / 计费口径(简版;完整对照见 voc-api-catalog 技能的「错误码速查」references/error-codes.md)
 
 - 缺 token(`needs_token`)= 可恢复、不是没钱:先自救读 `~/.claude/settings.json` 的 `sk-` fmode key(或 `~/.fmode/config.json` / `~/.claude/voc-credentials.json`),用 `FMODE_API_KEY=sk-…` 重试,不要劝充值。
-- 只有真 `402 余额不足` 才充值,入口 `https://app.fmode.cn/dev/studio/?balance=fmodeapi`(NewAPI/fmode-api 计费);`401`=token、`403`=权限,都不充值。`apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF` 仅限 `r:` 会话 token 余额补充
+- 只有真 `402 余额不足` 才充值;透传工具返回的 Tokenized Balance 链接或二维码支付结果。Balance 链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`;`401`=token、`403`=权限,都不充值
 - 实际充值/自救链接以工具返回的 `assistantMessage` / `nextActions` 为准,不要写死。
 
+## 媒体缓存规则
+
+- 真实采集响应中的图片、视频、音频、封面、买家秀和详情长图,统一先下载到本次输出目录的 `assets/` 并写入 `media-manifest.json`。
+- 报告只引用本地缓存路径;本技能包不调用线上 Storage、不申请预签名上传地址,也不把媒体上传到服务器。
+- 媒体下载失败时保留失败状态和说明,不回退为上游临时热链。
+
 ## 参考资料(按需展开阅读)
 
 - `references/user-workflow.md`:用户交互流程——第一次使用要问清的三件事、澄清话术、样例 vs 真实采集的判断。

+ 1 - 3
claude-code/claude-code-voc-intelligence/skills/xiaohongshu-trend-intelligence/references/live-mode.md

@@ -43,11 +43,9 @@ live 模式默认保持小规模:
 
 ## 充值入口(仅 402 余额不足时,简版)
 
-- `sk-` NewAPI/fmode-api 计费链(默认):`https://app.fmode.cn/dev/studio/?balance=fmodeapi`
-- `apig-pay/?apigid=Vo3ROWEvDy&fun_id=HOkkX72PMF` 仅限 `r:` 会话 token 余额补充。
+- 透传工具生成的 Tokenized Balance 链接或二维码支付结果;Balance 链接格式为 `https://app.fmode.cn/dev/studio/balance/?token=USER_SESSION_TOKEN`。
 - 完整 401/402/403 对照与回退逻辑见 **voc-api-catalog 技能的「错误码速查」references/error-codes.md**(单一权威来源)。
 
 ## 结论
 
 live 模式不是为了替代 sample,而是为了在 sample 已经校准过后,接上真实数据继续迭代。
-

+ 10 - 6
dist/claude-code-voc-intelligence-suite-manifest.json

@@ -1,9 +1,9 @@
 {
   "name": "claude-code-voc-intelligence",
-  "version": "0.3.32",
-  "generatedAt": "2026-07-30T09:56:01.468Z",
+  "version": "0.3.33",
+  "generatedAt": "2026-09-02T03:47:56.265Z",
   "packageZip": "dist/claude-code-voc-intelligence.zip",
-  "packageZipBytes": 687866,
+  "packageZipBytes": 2332018,
   "plugin": "voc-intelligence",
   "skills": [
     "xiaohongshu-trend-intelligence",
@@ -15,7 +15,8 @@
     "voc-competitor-map",
     "voc-business-workflow",
     "fmode-image-analysis",
-    "voc-api-catalog"
+    "voc-api-catalog",
+    "voc-cost-controller"
   ],
   "mcpTools": [
     "fmode_image_analysis",
@@ -33,7 +34,10 @@
     "voc_douyin_preference_update",
     "voc_api_search",
     "voc_api_doc",
-    "voc_api_call"
+    "voc_api_call",
+    "voc_billing_query",
+    "voc_cost_estimate",
+    "voc_usage_report"
   ],
   "installHint": "Supports three delivery modes: unzip and run node install.js, install globally into ~/.claude/plugins/voc-intelligence, or install into the current workspace. Workspace mode writes .mcp.json plus .claude/skills/* entries for trend intelligence, issue pool, deep dive, competitor map, content plan, speaking script, VOC business workflow, and Fmode image analysis so VSCode Claude Code can discover the workflows without relying only on --plugin-dir.",
   "installCommand": "node install.js",
@@ -44,5 +48,5 @@
   "buildCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package",
   "uploadCommand": "node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package --upload",
   "uploaded": false,
-  "copiedFiles": 142
+  "copiedFiles": 176
 }

BIN
dist/claude-code-voc-intelligence.zip


+ 6 - 6
dist/npm/claude-code-voc-npm-package-manifest.json

@@ -1,11 +1,11 @@
 {
   "name": "@vocmarket/voc-skill",
-  "version": "0.3.32",
-  "generatedAt": "2026-07-30T10:00:09.679Z",
-  "tarball": "dist/npm/vocmarket-voc-skill-0.3.32.tgz",
-  "tarballBytes": 582658,
-  "unpackedBytes": 4062488,
-  "entryCount": 140,
+  "version": "0.3.33",
+  "generatedAt": "2026-09-02T03:49:40.966Z",
+  "tarball": "dist/npm/vocmarket-voc-skill-0.3.33.tgz",
+  "tarballBytes": 662873,
+  "unpackedBytes": 4317876,
+  "entryCount": 166,
   "installCommands": [
     "npm install -g @vocmarket/voc-skill",
     "claude-voc install",