把 API 改造成 Pay Skill 服务端(402 / A2M)
何时用
- 用户说「把这个服务做成按次收费的」「上架付费技能」「接支付宝 AI 按量付费」「要给 API 加 402」
- 已有一个能交付价值的接口(遥感分析、数据查询、报告生成…),想按调用收费
- 已有 Skill 想从免费升级为 Pay Skill
不适用:纯网页/APP 收款(那是 AI 网页应用收款/AI 移动应用收款,另一套)、周期会员制。
第一步:先判通道(最容易判错的地方)
| 支付宝 AI 按量付费(402/A2M) | 平台代收(微信支付 AI 专属卡) | |
|---|---|---|
| 主体 | 个人可(实名即可) | 企业认证 + 微信支付商户号 |
| 商户号门槛 | 无 | 微信官方明示不支持无营业执照的小微商户 |
| 收款 | 你的服务返回 402,用户自己扫码付,钱进你的支付宝 | 平台代收后结算 |
| 代码量 | 自己实现 402 + 验付 + 履约确认 | 平台承接支付链路 |
★ 别只因为「微信商户号要营业执照」就断言个人不能收费 —— 那是把平台代收当成唯一通道。
自证判据:看 https://skillhub.cn/skills?category=pay-skill 有没有 user_xxx 个人账号在线。
(2026-09 实测:大量个人 Pay Skill 在线,多为支付宝 402 渠道。)
★ 付费技能发不了 CLI:skillhub publish 的 payload 没有 pricing/capability/x402/金额字段,
发出去永远是免费技能,还可能被判「站外交易引流」。
付费上架只能在 https://skillhub.cn/dashboard/publish 后台上传 zip →
填 slug/显示名/图标 → 分类选 pay-skill → 计费模式「按调用量计费」→ 填单价。
CLI 仍有用:skillhub publish <dir> --dry-run 能验 SKILL.md 元数据(slug/version/displayName)。
第二步:三处单价必须完全一致(不一致 → 买家付不了款)
| # | 位置 | 谁管 |
|---|---|---|
| 1 | 服务端 402 账单 amount |
代码(做成单一常量) |
| 2 | 支付宝 AI 付站点「服务管理 → 服务单价」 | 手填 |
| 3 | SkillHub 上架表单「单次调用价格」 | 手填 |
支付宝服务单价合法范围:0.01 ≤ 单价 ≤ 50.00 元,两位小数。官方表单原文:
> 「如实际支付时的价格和此处填写不一致将会导致用户无法支付,请谨慎填写!」
代码侧做法(防自己漂移):把单价放 service_config.py 一个常量 →
①模块导入时自检(范围/小数位)
②服务元信息与账单金额都从它取
③自检里断言「/service.json 的 unit_price 与 UNIT_AMOUNT 同源」。
文字说明里也要写同一个数字,别在文案里另写一个价。
★ 但沙箱联调时,账单金额不是你的售价
沙箱 mock 服务(serviceId = api_mock_service_id)在支付宝侧登记了固定单价 0.01 元
(官方五语言 A2M 示例统一用它)。账单金额与它不符,沙箱收银直接返回
SERVICE_PRICE_MISMATCH(服务价格不匹配) —— 2026-09-27 实测踩到,整个联调卡在这。
做法:两个常量 + 一个取价函数,绝不把沙箱价混进售价。
| 模式判据 | 402 账单金额 |
|---|---|
沙箱网关 且 serviceId = api_mock_service_id(严格沙箱) |
SANDBOX_UNIT_AMOUNT = 0.01 |
| 其余(含生产) | UNIT_AMOUNT = 真实售价 |
安全边界(缺一不可):
- 生产网关搭配沙箱 serviceId 在配置层直接报错 → 生产不存在「误用 0.01」的路径;
- 自检加断言「生产配置绝不降级为沙箱价」;
- 把当前实际账单金额暴露到诊断接口(runtime.effective_bill_amount + sandbox_mode),
部署后一眼可辨有没有用错价 —— 这是「三处单价」之外的第 4 个可观测点。
第三步:控制流(四步,状态码表)
probe → POST 业务请求,不带 Payment-Proof → 402 + Payment-Needed(Base64URL 账单)
pay → 把账单交给支付宝官方支付能力拉起收银,用户本人付(Agent 只编排,不代付)
complete→ 带 Payment-Proof 重试同一请求 → 服务端 verify → 交付
ack → 交付后 fulfillment.confirm 回执(幂等)
状态码必须区分清楚,别混:
| 情况 | 返回 | 说明 |
|---|---|---|
| 输入非法 | 400 |
★ 必须在发账单之前拦 —— 不能因非法输入收钱 |
| 收款渠道未配置 | 503 |
★ 明确拒绝,不是免费放行;没有免费旁路 |
| 未带凭证 | 402 + Payment-Needed |
正常路径 |
| 验付失败 / 过期 / 金额不符 / 资源不符 / 交易号不符 / 订单不存在 | 402 |
回落发新单 |
| 验付网关不可达(结果"不明") | 502 + retry_with_same_proof=true |
★ 不得回落 402 —— 那会诱导用户重复付款 |
| 履约确认失败 | 502 |
保留交付物,同一凭证可重试 |
| 通过 | 200 + Payment-Validation |
交付 |
参考实现分层(照这个拆好维护、好自检)
| 文件 | 职责 |
|---|---|
service_config.py |
计费与商品唯一事实来源 + 导入时自检 |
alipay_config.py |
配置加载(环境变量 > 本地受保护 json);missing_fields() 区分「未配置」与「校验失败」;缺失→抛错,不占位兜底 |
a2m_gate.py |
402 门禁:账单 / 验付 / 交付 / 履约;验付与履约做成可注入便于离线自检 |
order_repository.py |
SQLite 订单仓库:create_pending/find_by_out_trade_no/prepare_fulfillment/mark_fulfilled |
main.py |
路由:先校验输入 → 再查配置 → 才进 402 门禁 |
selftest_a2m.py |
离线自检(不联网,注入替身) |
核心不变式:
- 先落盘再应答 —— 账单一旦发出,服务端必须已有对应订单,否则支付后无单可验。
- 交付物只生成一次 —— 用写事务(
BEGIN IMMEDIATE)识别重复订单,已交付直接回既有结果。 - 金额 + 资源 + 币种 + 交易号四项全对才交付。
- 履约确认失败可重试且不重造交付物(不重复扣费、不重复交付)。
- 私钥不进包、不进日志(
repr(config)用<hidden>)。
★ 十个必踩的坑
- 签名参数集必须能从账单反推。
账单里若把goods_name/seller_id拼进签名串,却没放进protocol块,
验签方拿不到原始入参 → 签名无法被独立验签。
修法:抽出公开函数bill_sign_params(...),签名与验签同源;自检里用「账单里能拿到的字段」重建参数验签,并加一条篡改金额后验不过的反例。 - 验付网关不可达 ≠ 凭证无效。 网络异常时回落 402 会让用户白付一次。用
502+retry_with_same_proof=true,
并写进 SKILL.md 的异常处理表。 - Crypto 不要在模块顶层 import。 部署环境漏装
pycryptodome/SDK 时,顶层 import 会让整个进程起不来,
免费接口也一起挂。改成函数内导入 + 暴露payment_deps_available()到诊断接口。 - 平台会拦截
GET /healthz。 部署平台常返回它自己的{"code":200,"msg":"Health Checked"},
你写的健康检查看不到。诊断字段要放别的接口(如/v1/.../schema的runtime块)。 - 单价漂移。 见第二步;代码侧靠单一常量 + 同源断言,文案侧人工核对。
- 免费旁路。 任何返回真实交付物的免费接口 = 直接绕开收费。免费接口只能给结构说明/示例/元信息。
- 反代改写 Host。 部署平台会把
X-Forwarded-Host写成沙箱内网主机名
(*-<id>.e2b.*.sandbox.*),直接回显会让元信息给出用户根本用不了的地址。
加内部主机名识别 +PUBLIC_BASE_URL常量(环境变量可覆盖),且改完必须重新发布才生效。 - 沙箱登记价 ≠ 售价 →
SERVICE_PRICE_MISMATCH。 见第二步的「沙箱联调」小节。
症状:收银接口 HTTP 200,但返回体errorCode: SERVICE_PRICE_MISMATCH;账单金额写成售价就必挂。 - 源码/文案里别写
pycryptodome这个包名(假阳性陷阱)。
官方 implementation audit 的PAYMENT_CRITICAL_TODO规则是
(?:TODO|FIXME)[\s\S]{0,240}(支付|订单|验付|验签|退款|履约|持久|幂等|payment|order|verify|signature|refund|fulfill|persist|idempot)
带iu标志 ——pycryptodome里的todo会命中,只要同一个 240 字符窗口内还有支付词,
就报「支付关键控制仍保留 TODO/FIXME」,代码没问题也过不了。2026-09-27 在a2m_gate.py/main.py/
README.md三处被拦,白折腾一轮。
修法:注释与文案统一写「Crypto 加密库」,包名只留在requirements.txt(其后无支付词,不触发)。
自证要用同款正则(new RegExp(pattern,'iu'))扫 0 命中 ——
别用grep TODO|FIXME:它区分大小写、也没有 240 字符窗口,搜不出假阳性,会误判成「工具坏了」。 - 履约确认不能吞异常。
except Exception: return False会把 502 变成无从排查的黑盒
(实测:同一笔交易单独重试就成功,但没日志时根本判断不出该不该重试)。
必须logger.warning留痕(trade_no+ 异常类型 +code/sub_code/sub_msg),
并在服务入口logging.basicConfig。「保留交付物 + 允许同一 Payment-Proof 重试」的设计是对的,留痕才用得上它。
自检必须覆盖的断言(离线跑,退出码非零不许交付)
配置层:单价范围/小数位 · 描述长度上限 · 元信息单价与常量同源 · repr 不泄私钥 · 缺失即失败 · 生产网关不搭沙箱 serviceId
账单层:字段集合与协议一致 · 账单金额 = 单价 · 资源路径 · 签名可被公钥验签 · 篡改金额后验不过 · 先落盘再应答 · 两种私钥写法(PEM / base64(DER))都能签
验付拒绝:乱码凭证 · 缺字段 · code≠10000 · active≠True · 金额不符 · 资源不符 · 交易号不符 · 订单不存在 · 生产模式无沙箱回退 —— 全部回落 402
交付幂等:交付物只生成一次 · 重复请求 already_fulfilled=true 且不重复扣费 · 履约失败 502 可重试且不重造交付物 · 支付窗口过期 → 402
输入校验:非方阵/负值/全零/字段数不符/枚举非法 —— 全拦在发账单之前
★ 写自检时别踩的两个假阳性(都真摔过)
- 别用整串子串匹配判「没有二维码/链接」:签名的 base64 里随机出现
qr是正常的,
"qr" in json.dumps(bill).lower()这类断言会随签名变化随机失败(价格一变签名就变 → 断言翻车)。
改成判字段名(url/link/qr是否出现在键名里)+ 取值形态(是否有值以http开头)。 - 「配置缺失必须失败」的用例要先隔离配置路径:本机一旦存在真实配置文件(沙箱联调时必然存在),
这条断言就会被真实文件掩盖成假通过。做法:临时把SANDBOX_CONFIG_FILE指向不存在的路径,
跑完在finally里还原;再补一条条件断言「若真实配置存在,则必填项必须齐全」。
上架前核验清单
- [ ] 服务端公网 HTTPS,且验付/履约已通(线上真实交易验证过,不是沙箱)
- [ ] 三处单价一致(服务端账单 = 支付宝服务登记 = 上架表单)
- [ ]
SKILL.mddry-run 通过;无密钥、无.env、无支付账号字段(grep 一遍) - [ ] 免费接口只有结构说明,无付费旁路
- [ ] 服务端入驻六步做完:SkillHub 商户中心入驻 → 支付宝签约「AI 按量付费」→ 注册服务拿
API_serviceId → RSA2 密钥(私钥只留服务端)→ 公网 HTTPS 部署 → 0.01 元真实交易验证回填交易号
SKILL.md(Pay Skill 包)要写的章节
- 何时使用 / 不适用
- 交付物(列清返回什么)
- 输入字段表
- 付费模式(支付宝 AI 按量付费 · A2M 协议) ← 这段是审核重点,必须含:
- 单次调用价格(与另两处一致)
- 服务地址 / 方法 / 免费的结构说明地址
- 四步编排 probe / pay / complete / ack
- 前置检查(智能体是否有支付宝支付工具;没有就终止并让用户装官方「支付宝 AI 付」)
- 异常处理表(400/402/502/503/用户取消 各怎么办)
- 安全红线(不暴露支付链接/二维码/交易号;私钥只存服务端;包内无密钥)
- 服务端入驻六步(标注:发布者侧完成,本包不含密钥)
- 履约内容说明 - 上架前核验清单
- 参考链接
已知工具/环境坑(顺带)
curl -o /dev/null在 Windows 上 exit 23(致命):Pythonsubprocess直调系统
C:\WINDOWS\system32\curl.exe(不经 MSYS 路径转换)时,Windows 原生 curl 不认/dev/null
(它认nul),于是官方references/integration/modules/scripts/local_402_sandbox_pay.py的
a2m precheck/a2m run一上手就报「curl 执行失败,退出码 23」,HTTP 头明明已经拿到了。
修法:把脚本里三处"-o", "/dev/null"改成"-o", os.devnull(os本来就已 import,脚本其余位置
已用os.name != "nt"保护过权限位检查,只有这一处漏了)。
换电脑/重装 skill 后再报 23,先grep -c "/dev/null" <脚本>确认补丁还在不在。- Windows + Git Bash:
python -c看不到 Git Bash 的/tmp(要用 Windows 路径);
for p in "/"这类参数会被 MSYS 转成 Windows 路径(加MSYS_NO_PATHCONV=1)。 - 命令后台起的服务会随该次工具调用结束而被回收:起服务 + curl 验证要写在同一次调用里。
(长任务用后台任务方式起,可跨调用存活。) - 本地 pip 装不上时先排网络出口:某些受限网络下清华源
from versions: none但curl同源却是 200,
极容易被误判成「没网」;换官方源即可。 - 部署前先想「含私钥的配置文件会不会被打包」:部署平台按目录打包,
.gitignore不保证被遵循。
★ 2026-09-27 更正:workbuddy_sites_deploy没有环境变量入口(参数里没有 env/secrets),
所以"改平台环境变量注入"在该平台做不到 —— 线上配置只能以项目内文件随包上传。
落地前先探两件事(都实测过,别凭感觉):
①GET /main.py、/README.md→ 通常 404(反代只转发应用路由,源码不外泄);
②GET /.env、/.gitignore→ 通常 403(敏感名被反代直接拦)。
所以用项目内.env是可行的;但沙箱配置必须移出项目目录(放../secrets/),
否则线上配置漏填时服务会静默降级用沙箱密钥发账单。自检里加一条
「目录内残留含api_mock_service_id的配置 = FAIL」把这条钉死。
详细步法与脚本见技能alipay-aipay-windows-setup的 §17。