💳 HoopSpark 内购 —— 线框图与实施计划(iOS + Android)

HOOP 系统设计图纸 · 正文唯一真相是 docs/iap.md;改 md 再跑 ./tools/deploy_docs.sh,这页是生成的

版本 026801db7
2026-09-22 16:49
档案
name iap
summary HoopSpark 内购(iOS + Android,六档):现状盘点 + 线框图 + 实施计划。iOS 那一半代码早就写完了、卡在 App Store Connect;Android 一行都没有。这份图纸把两边收进同一条后端管线,并把六档从美分重锚成马币。

💳 HoopSpark 内购 —— 线框图与实施计划(iOS + Android)

这份是什么:Leong Sen Fong 2026-09-22 06:09 要的「完整线框图 + 实施计划」, 要求先查清现有实现再设计,能直接照着开工

🇬🇧 English version: /iap-en —— 同样的内容、同样的结构。

最重要的一句话,先说在前面:这不是从零开始的项目。 iOS 那一半已经建完了 —— 离线验签、幂等入账、退款回扣、充值页,全都在生产分支上, 卡的是 App Store Connect 那几步人工配置。Android 是真的一行都没有。 所以下面所有方案都是「补齐 + 统一」,不是「另起炉灶」。


零、先看清楚现在有什么(全部现查,不是照抄旧文档)

核查基准:origin/master @ e9523ea8b,2026-09-22。

0.1 已经建好、能用的(iOS)

位置 状态
苹果交易离线验签 backend/internal/domain/iap/verify.go ✅ 完整。内嵌 Apple Root CA G3,验 x5c 证书链 + 有效期 + ES256 签名,不依赖苹果服务器可用性
业务校验 verify.go Check() ✅ bundle、环境(沙盒不给放行)、撤销日期、未知商品、quantity > 1 按份给
幂等入账 repository.go Credit() ✅ 台账 + 钱包 + 流水同一个事务;iap_transactions.transaction_id 做主键 = 幂等键,重放撞主键
退款回扣 repository.go Refund()
退款追回收款方那一份 没有 —— Refund() 不碰 dev_ledger ❌ 见 §十一
提现冻结期 没有 ❌ 见 §十一
CONSUMPTION_REQUEST 应答 没有(verify.go:202 只在注释里提过) ❌ 见 §十一
苹果服务器通知 V2 handler.go notify() ✅ 免鉴权、靠 JWS 验签认身份;REFUND / REVOKE 自动扣回
档位表 products.go ✅ 六档,到账数额只认服务端,客户端报的一律不信
客户端购买流 app/lib/services/iap_service.dart ✅ 194 行。后端 200 之后才 completePurchase() —— 这条铁律让掉线/闪退都能自愈
充值页 app/lib/screens/topup_screen.dart ✅ 292 行。价格走 StoreKit 本地化字符串、恢复购买、不可退款声明、条款链接
启动即订阅 app/lib/main.dart:193 ✅ 登录后就 start(),不等用户打开充值页 —— 上次没 finish 的交易才有人接
数据表 迁移 000172 iap_transactions + wallet_ledger
插件 pubspec.yaml:104 in_app_purchase: ^3.2.1 这个插件本身两个平台都支持,Android 那一半是现成的

客户端那条铁律值得单独念一遍(iap_service.dart 抬头):

后端返回 200(已入账)之后,才 completePurchase()

反过来做,一旦网断或闪退,交易就被商店永久标记为已完成,我们再也拿不到它 —— 用户付了钱、Spark 凭空消失,而且无法补救。正因为守住了这条, 未 finish 的交易下次启动订阅时会被重新吐出来,自动重投,后端靠主键幂等。 Android 那一半必须照搬这条,一个字都不能松。

0.2 完全没有的(Android)

我按 androidpublisher / play.?billing / purchaseToken / google.?play 四个词扫过 backend/app/lib/,后端零命中(只有图纸里提到过"没做")。具体缺:

  1. Google Play Billing 的服务端校验(需要 Google Play Developer API + 服务账号凭据)
  2. Real-time Developer Notifications(RTDN,走 Pub/Sub)—— 苹果那一侧的对应物已经有了
  3. iap_transactions 没有 platform 列,主键是苹果的交易号
  4. Play Console 里六个商品还不存在
  5. android/app/src/main/AndroidManifest.xml 里没有 com.android.vending.BILLING —— ⚠️ 这一条不一定是缺口:in_app_purchase_android 自己的 manifest 里声明了, Gradle 会合并进来。开工第一件事是拆一个 release 包 aapt dump permissions 确认, 别照着"我们没写"就去加一行可能重复的。

0.3 生产上的真实数据(刚查的)

iap_transactions   0 行     —— 从来没有人买过
payouts            0 行     —— 从来没有人提过现
dev_ledger        24 行 / 6 个开发者 / 1229 Spark(≈ MYR 12.29)

这个 0 是这份计划里最值钱的一个数:重锚档位几乎没有代价 —— 没有购买历史要兼容,没有老用户的余额是按旧价买来的。现在改,是最便宜的时刻。

⚠️ 但 0 笔交易证明不了 Play / ASC 里商品不存在。product id 一旦在控制台建过就永久不可复用, 开工第一步是登录两个控制台各看一眼,那是我看不到的地方。


一、六个档位怎么组织

1.1 商品类型:六个全是消耗型,没有非消耗型,没有订阅

  • 消耗型(Consumable):买了变成 Spark,花掉就没了。六档全是这一类。
  • 非消耗型:没有。HOOP 没有"买一次永久拥有"的东西。
  • 订阅:商店订阅一个都没有,也不该有。 ⚠️ 这里有个容易搞错的地方:AI 助手确实有 weekly / monthly / yearly 三档 (assistant.go:88-92,400 / 1200 / 12000),但它们是用 Spark 付的,不是商店订阅 —— 钱包扣数,不走 StoreKit / Play Billing。 判据:真钱只在"买 Spark"这一个口进来,进来之后全是 Spark 内部流转。 这个设计要守住 —— 一旦再开一个真钱入口,退款、税务、对账就要各做两套。

1.2 六档的定价:Spark 数量不动,价格直接锚在马币上

🗓️ 2026-09-22 Leong 定的口径:六档的 Spark 数量一颗都不改, 价格直接用马币定,不许先算美元再换算。 这一节原先建议的是"另设六档、把赠送梯度收窄"——那一版已作废,原因见 1.2.3。

1.2.1 定价表(唯一真相,products.go 照这张改)

底价锚死在李敏 2026-09-15 定的那条:100 Sparks = MYR 1。 最低档就坐在这个汇率上,更高档不是降价,是同样的钱给更多 Spark

product id 售价 Spark Spark/MYR 赠送(相对最低档) 定位
com.hooptech.hoop.spark.500 MYR 5.00 500 100 入门,基准汇率
com.hooptech.hoop.spark.1600 MYR 14.90 1,600 107 +7%
com.hooptech.hoop.spark.3400 MYR 30.00 3,400 113 +13%
com.hooptech.hoop.spark.6500 MYR 55.00 6,500 118 +18% 最受欢迎(UI 高亮)
com.hooptech.hoop.spark.14000 MYR 110.00 14,000 127 +27%
com.hooptech.hoop.spark.34000 MYR 250.00 34,000 136 +36% 大额

赠送比例一个都不用改:算出来正好落在 products.go 里已经写着的 BonusPct 0 / 7 / 13 / 18 / 27 / 36 上,所以 UI 角标原样能用。

⚠️ 取整一律往下取(14.95 → 14.90,55.08 → 55.00)。方向是故意挑的: 往下取 = 用户拿到的赠送只会比角标写的多,不会少。反过来就是角标在骗人。

1.2.2 把最坏情况的账算完(这是定价唯一真正的约束)

最坏情况 = 一整档 Spark 全部用于音乐打赏,创作者拿 50%(revshare.go:27)。 我们那一侧要付的钱按记账价 100 Spark = MYR 1 算。

Spark 售价 苹果抽 30% 后我们净收 最坏要付创作者 结余 小企业计划(15%)下的结余
500 5.00 3.50 2.50 +1.00 +1.75
1,600 14.90 10.43 8.00 +2.43 +4.66
3,400 30.00 21.00 17.00 +4.00 +8.50
6,500 55.00 38.50 32.50 +6.00 +14.25
14,000 110.00 77.00 70.00 +7.00 +23.50
34,000 250.00 175.00 170.00 +5.00 +42.50

六档在最坏情况下全部为正 —— 这正是重锚要解决的事: 旧的美元定价里最贵那档是 −MYR 12.53(算式见 1.2.4,留着当对照)。

⚠️ 但要看清楚薄在哪:最贵那档的 +MYR 5.00 只有售价的 2%。 原因不是价格定低了,是36% 的赠送梯度 + 50% 的创作者分成一起吃掉了苹果留下的那部分。

🔴 所以这一节真正要他知道的一句: 报名 App Store 小企业计划(抽成 30% → 15%),比这张表上任何一个价格点都值钱 —— 同一张表,最贵那档的结余从 MYR 5 变成 MYR 42.50,翻了八倍多,一个字都不用改定价。 这件事已经在 §10.1 的清单里,但它在那儿只是一个勾;它其实是这条线上最高回报的一步

1.2.3 为什么放弃了原来那版"收窄赠送梯度"的建议

原先这一节建议另设六档(500 / 1,050 / 2,750 / 5,700 / 11,800 / 24,000,赠送收到 +18% 封顶), 目的就是把上面那个 2% 的薄利撑开。Leong 09-22 的决定是保留现有 Spark 数量, 所以那一版作废。

代价照实记在这儿,不藏:保留 36% 的赠送梯度,就等于接受最贵那档在最坏情况下只有 2% 的结余。 这不是错的选择 —— 它换来的是档位表不用重建、角标不用改、用户看到的内容量不缩水。 但它把"小企业计划"从一个可选项变成了实质上的前提

1.2.4 旧美元定价的账(留作对照,不要照着做)

玩家付            US$49.99  ≈ MYR 224.96(按 1 美元 = 4.5 马币)
苹果抽 30%        →  我们净收 MYR 157.47
这一笔造出        34,000 Spark
若全部用于音乐打赏(创作者 50%)
  → 要付给创作者 17,000 Spark = MYR 170.00
净结果            157.47 − 170.00 = −12.53   ❌ 亏

1.2.5 这张表还不能直接填进控制台

⚠️ 「售价」不等于「能直接填进去的价格」:两个商店都是从各自的价格档位表里挑, 不是任填一个数。上表是目标锚点,实际要在控制台里选最接近的那一档 —— 必须人在控制台里确认,我不替它编一张苹果的马来西亚价格表。 挑完之后回来把这张表改成真填进去的数,并重算一次 1.2.2 那六行。

1.3 Product ID / SKU 命名

规则:com.hooptech.hoop.spark.<Spark 数量>

  • 前缀和 bundle / applicationId 一致(com.hooptech.hoop,两个平台同一个,见 build.gradle.kts:50)
  • spark 不用 green:对外名字 2026-09-15 就改了,新建的 id 永久不可改, 别把一个已经退休的名字永久刻进去
  • 后缀用 Spark 数量不用档号:t1…t6 读不出内容,而数量一旦定了就不会变 (要变就是新商品了,反正也得建新 id)
  • 两个平台用同一串 id —— 后端那张映射表才能只有一份

🗓️ 2026-09-22 之后这一节多了一条便宜路,值得先读

Spark 数量不再变动之后,「必须新建六个商品」的理由就只剩改名这一条了 —— 而 product id 用户根本看不见:topup_screen.dart:153 只拿它当查价的键 (_svc.products[id]),一个字都不画在屏幕上。

所以如果阶段 0 查出来 ASC 里 …green.500 那六个已经建过了: 直接改那六个的价格就行,不用新建六个、下架六个。 价格是可改的, 而 Spark 数量本来就写在我们后端的表里、跟商店无关。

判据:这次重锚改的是价格,不是内容量。 只有内容量变了才非得换新 id。 代价是 green 这个退休名字会永久留在 id 里 —— 但它只活在两个控制台的后台列表里, 不在任何用户看得到的地方

⚠️ 两条路都要人在控制台确认之后才能定,这是阶段 0 的事。

⚠️ Google Play 的 id 规则更严:只允许小写字母、数字、下划线、句点,且必须以字母或数字开头。 上面的命名两边都合法,但两边都是建了就永久不可复用(Apple 明确不可复用; Play 删除后同名也不能再建)。这张表定稿之前别急着在控制台按"创建"。


二、线框图(逐屏 + 每个状态)

2.1 屏幕清单

# 现状
A 钱包 / 余额入口 已有
B 充值页(六档选择) 已有 topup_screen.dart
C 商店原生购买弹层 商店画的,我们不控制
D 到账庆祝 已有(SnackBar)
E 失败 / 取消 部分有(只打日志,没给用户看的话)⚠️
F 恢复购买 已有(顶栏按钮)
G 商品不可用 已有 _unavailable()
H 购买历史 没有。后端 GET /v1/wallet/ledger 在,api_client.dart:4551 也封装好了,零调用方

2.2 B 充值页 —— 主屏

┌──────────────────────────────────────┐
│  ←   Top up                 Restore  │   ← Restore 在 pending 时禁用
├──────────────────────────────────────┤
│  ┌────────────────────────────────┐  │
│  │  Your balance                  │  │
│  │  ❇️  1,250                     │  │   ← 读不到时画「—」,不画 0
│  │  Sparks are spent inside HOOP  │  │
│  └────────────────────────────────┘  │
│                                      │
│  ┌──────────┐  ┌──────────┐          │
│  │   500    │  │  1,600   │          │
│  │  Sparks  │  │  Sparks  │          │
│  │          │  │  +7%     │          │
│  │ RM 5.00  │  │ RM 14.90 │          │   ← 价格必须是商店回的本地化字符串
│  └──────────┘  └──────────┘          │
│  ┌──────────┐  ┌──────────┐          │
│  │  3,400   │  │  6,500   │ POPULAR  │
│  │  +13%    │  │  +18%    │          │
│  │ RM 30.00 │  │ RM 55.00 │          │
│  └──────────┘  └──────────┘          │
│  ┌──────────┐  ┌──────────┐          │
│  │ 14,000   │  │  34,000  │          │
│  │  +27%    │  │  +36%    │          │
│  │ RM 110   │  │ RM 250   │          │
│  └──────────┘  └──────────┘          │
│                                      │
│  Prices are shown in your local      │
│  currency by the store. Hoop Spark   │
│  is non-refundable, cannot be        │
│  transferred or cashed out.          │
│           Terms · Privacy            │
└──────────────────────────────────────┘

不能改的三条(topup_screen.dart 抬头写着,两个商店审核都会看):

  1. 价格必须显示商店返回的本地化字符串,写死会被拒,而且外区用户看到错币种
  2. 底部必须写清「不可退款 / 不可转让 / 不可提现」+ 条款 / 隐私链接
  3. 「恢复购买」不是摆设 —— 消耗型商品商店不提供恢复,但这个动作会把 未完成的交易重新吐进购买流,是用户「钱扣了没到账」时的自救出口

2.3 每个状态长什么样

① 加载中          骨架卡 ×6,顶栏 Restore 禁用
② 正常            如上
③ 某一档处理中     那张卡的价格位置换成转圈;其余五张一起禁用(一次只处理一笔)
④ 购买成功        绿色 SnackBar:⚡ 500 Sparks added;余额卡当场跳数
⑤ 用户取消        安静收场,不弹任何东西     ← 取消不是错误,别拿弹窗惩罚用户
⑥ 购买失败        红色 SnackBar + 「Try again」;⚠️ 现在这一条只打日志,是要补的
⑦ 已入账过(幂等)  「Already added」,余额刷新,不重复庆祝
⑧ 付款成功但入账没确认  ⚠️ 最关键的一屏,现在没有:
                     「Payment received. Adding your Sparks…
                      This can take a moment. You can close this page —
                      it will finish by itself.」
                     ← 不许说失败(钱真的收了),也不许说成功(还没到账)
⑨ 商店不可用       「The store isn't available right now」+ Retry
⑩ 商品查不到 0 个   同 ⑨ —— 最常见的原因不是代码错,是控制台协议/税务/银行没填完
⑪ 待处理(Android) 「Waiting for your payment to clear」
                     ← Android 特有:某些付款方式(如便利店代付)会挂几天

⑧ 和 ⑪ 是这次要新建的两个状态,它们都不是装饰: ⑧ 是"钱扣了没到账"那几秒里用户唯一看得到的解释; ⑪ 是 Android 独有的 PENDING 交易,iOS 上几乎见不到,没有它用户会以为买失败了再买一次

2.4 H 购买历史(新建)

┌──────────────────────────────────────┐
│  ←   Spark history                   │
├──────────────────────────────────────┤
│  ❇️ +500      Top up        22 Sep   │
│     RM 5.00 · Apple                  │
│  ❇️ −1,200    AI assistant  20 Sep   │
│  ❇️ −50       Tip · 远方山岗 19 Sep   │
│  ❇️ +100      Daily check-in 18 Sep  │
│  ❇️ +1,000    Welcome gift  01 Sep   │
├──────────────────────────────────────┤
│         Load more                    │
└──────────────────────────────────────┘

后端已经在了(wallet_ledger,kind 分 grant / iap_topup / purchase / tip / refund / admin / checkin),api_client.dart:4551 也封装好了 —— 只差一个页面。 ⚠️ 账本 2026-08 才起算,更早的余额变动没有流水,页面底部要说清这件事, 别让用户以为我们弄丢了他的记录。

2.5 导航

底栏「我」→ 钱包卡 ─┬─→ [Top up]      → B 充值页 ─→ C 商店弹层 ─→ D/E
                   └─→ [History]     → H 购买历史(新)

三、用户流程(状态图)

        ┌──────────────┐
        │  idle        │
        └──────┬───────┘
               │ 点某一档
               ▼
        ┌──────────────┐   商店弹层取消
        │  purchasing  ├───────────────→ idle(安静,不报错)
        └──────┬───────┘
               │ 商店回 purchased / restored
               ▼
        ┌──────────────┐
        │  redeeming   │  ← 拿凭证去后端换 Spark
        └──┬────────┬──┘
           │        │
      后端 200      后端非 200 / 超时 / 断网
           │        │
           ▼        ▼
     ┌─────────┐  ┌──────────────────────────┐
     │ credited│  │ unconfirmed              │
     │ finish()│  │ **绝不 finish**           │
     └─────────┘  │ 交易留在商店那儿          │
                  │ 下次启动重投 → redeeming  │
                  └──────────────────────────┘

Android 多一条:
        purchasing ──→ ┌─────────┐ 付款几天后清算 ┌──────────┐
                       │ PENDING ├──────────────→│ PURCHASED│→ redeeming
                       └─────────┘                └──────────┘
                       ⚠️ PENDING 期间**一个 Spark 都不许给**

这张图的核心只有一句:creditedfinish() 之间不许有任何可能失败的步骤, 而 unconfirmed 必须是一个能自己走出去的状态,不是死胡同。


四、两个商店各要配什么

4.1 Apple / App Store Connect

# 步骤 谁做 卡住会怎样
1 签「付费 App 协议」(Paid Apps Agreement) 账号持有人 商品一个都查不到,充值页显示「不可用」
2 填税务表 + 银行账户 账号持有人 同上 —— 协议不算生效
3 建 6 个 Consumable 商品,id 用 §1.3 那张表 控制台
4 每个商品填本地化名称 / 描述 + 审核截图 控制台 商品过不了审
5 设价格(从马来西亚价格档里挑最接近 §1.2 的) 控制台
6 建沙盒测试账号 控制台 没法测
7 App Store Server Notifications V2 URL → POST /v1/iap/apple/notify 控制台 退款不会自动扣回
8 报名 小企业计划(抽成 30%→15%) 账号持有人 白送苹果一半利润(见 §1.2 的账)

✅ 代码侧:第 7 步那个端点已经写好了,验签、REFUND/REVOKE 扣回都在。

4.2 Google / Play Console

# 步骤 谁做 备注
1 开发者账号 + 商家账号(收款) 账号持有人 US$25 一次性
2 先上传一个 release 包到内部测试轨 NOVA ⚠️ 包没上去之前,商品建不了
3 建 6 个 一次性商品(消耗型),id 同 §1.3 控制台 两边 id 必须一样
4 设价格(马来西亚) 控制台
5 服务账号,授 Play Developer API 权限,下 JSON 凭据 账号持有人 🔑 凭据走文件到服务器,永不进 git、永不贴进 HOOP
6 RTDN:建 Pub/Sub 主题 → 推送到 POST /v1/iap/google/notify 控制台 对应苹果第 7 步
7 建内部测试轨 + 测试账号 控制台 测试账号买东西不真扣钱

4.3 两边真正不一样的地方(这些是会咬人的)

Apple Google
凭证 JWS 签名交易,可离线验签 purchase token,必须在线调 Google API 换
我们要不要密钥 ❌ 不要(内嵌根证书就够) 要服务账号凭据 —— 多一个要保管的秘密
幂等键 transactionId orderId(⚠️ 某些情况可能为空,要用 purchaseToken 兜底)
认人 appAccountToken obfuscatedAccountId
消耗 finishTransaction 必须显式 consume,不消耗就不能再买同一档
待处理交易 罕见 常见(便利店 / 转账付款,可能挂几天)
退款通知 Server Notifications V2 RTDN(Pub/Sub 推送)+ Voided Purchases API
沙盒 沙盒账号,交易免费 测试轨 + 授权测试账号

⚠️ 「必须在线验」这一条会改变可用性设计:苹果那边 Google 挂了也照样能到账, Android 那边 Google API 挂了就必须走 unconfirmed 那条路(不 finish、不 consume、下次重投)。 好消息是这条路已经为苹果建好了,Android 直接复用。


五、架构

5.1 分层(沿用现有,不引入新东西)

┌─ UI ───────────────────────────────────────────┐
│  topup_screen.dart        六档 · 状态 · 文案    │
│  spark_history_screen.dart(新)                 │
└───────────────┬────────────────────────────────┘
                │ 只读 IapService 的字段,不碰商店 SDK
┌───────────────▼────────────────────────────────┐
│  IapService(已有,扩成两平台)                   │
│  · 订阅购买流   · 拉档位  · 发起购买             │
│  · **后端 200 之后才 finish**                   │
└───────────────┬────────────────────────────────┘
                │ ApiClient
┌───────────────▼────────────────────────────────┐
│  后端 domain/iap                                │
│  handler.go   products / verify / notify /      │
│               **google_verify / google_notify(新)** │
│  verify.go    苹果离线验签(已有)                │
│  google.go    Google 在线校验(新)              │
│  repository.go 幂等入账 · 退款回扣(已有,加平台列)│
└───────────────┬────────────────────────────────┘
                ▼
        Postgres:iap_transactions · wallets · wallet_ledger

刻意不做的事:不引入新的支付中台、不引入新的状态管理库、不给 Android 单开一张表。 in_app_purchase 插件两个平台都支持,后端那条管线(验 → 幂等入账 → 流水)也两个平台通用 —— 要分叉的只有"怎么验凭证"这一步

5.2 后端新增面

端点 作用 鉴权
POST /v1/iap/google/verify 拿 purchase token 换 Spark 用户令牌
POST /v1/iap/google/notify RTDN 推送(退款 / 撤销) 免鉴权,靠 Pub/Sub 的 JWT 验签认身份 ⚠️

⚠️ google/notify 那个端点和苹果那个一样是开在公网上、不带我们令牌的口。 苹果那边靠 JWS 证书链认身份;Google 那边必须验 Pub/Sub 推送的 OIDC token (aud 要对上我们自己的端点,email 要对上那个推送服务账号)。 这一步漏了 = 任何人都能 POST 一条"退款"把别人的 Spark 扣光。verify.go 的样子做:验完才动账。


六、数据库

6.1 现有表(不动结构,只加列)

iap_transactions
  transaction_id          TEXT PRIMARY KEY   -- ⚠️ 现在装的是苹果交易号
  original_transaction_id TEXT
  user_id                 UUID → users(id) ON DELETE CASCADE
  product_id              TEXT
  green                   BIGINT             -- 服务端算的,不信客户端
  environment             TEXT               -- Sandbox / Production
  status                  TEXT               -- credited / refunded
  purchased_at            TIMESTAMPTZ
  created_at              TIMESTAMPTZ
  raw_jws                 TEXT               -- 原始凭证,留证据

wallet_ledger
  id, user_id, kind, green, balance, ref, created_at

6.2 这一版要加的(一条迁移)

-- 取号去 backend/migrations/LEDGER.md,拿走立刻 +1 写回
ALTER TABLE iap_transactions
  ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'apple',   -- apple / google
  ADD COLUMN IF NOT EXISTS purchase_token TEXT NOT NULL DEFAULT '',  -- Google 的,苹果为空
  ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'credited',   -- pending / credited / refunded
  ADD COLUMN IF NOT EXISTS currency TEXT NOT NULL DEFAULT 'MYR',     -- 商店实收币种(买家所在区)
  ADD COLUMN IF NOT EXISTS price_micros BIGINT NOT NULL DEFAULT 0;   -- 商店实收金额,对账用

-- Google 的幂等键:orderId 可能为空,purchase_token 才是那个永远在的
CREATE UNIQUE INDEX IF NOT EXISTS iap_tx_google_token
  ON iap_transactions(purchase_token) WHERE purchase_token <> '';

几个决定和它们的理由:

  • 不另开一张 google_transactions 表。 两个平台的字段 90% 重合,分表之后 "这个用户一共充了多少"要写两遍查询,而两处实现不一致时屏幕上看不出来(铁律 23)。
  • platform 给默认值 'apple' 现在表是空的(0 行),但默认值让这条迁移 即使在有数据的环境上跑也不会留下空值。
  • purchase_token 上是 partial unique index,不是主键。 苹果那一侧这列是空字符串, 空串不能全表唯一 —— WHERE purchase_token <> '' 才对。
  • price_micros + currency:商店实收的钱。这是我们唯一能拿到"用户真付了多少"的地方, 而买家在哪个区就付哪种币(这是已拍板接受的例外)。没有这两列,对账时只能靠猜。 ⚠️ 顺带一提,payouts至今没有币种列 —— 那是钱真正流出去的地方, 比这张表更该有,已单独报过。

6.3 不需要的表

  • products 表:档位表活在代码里(products.go),改档位不需要迁移。 ⚠️ 别"顺手"把它搬进数据库 —— 那会变成第二个出处,而且到账数额必须是编译进二进制的, 不能是一张谁都能 UPDATE 的表。
  • entitlements 表:Spark 就是余额本身,wallets.green 已经是 entitlement。 再加一张会变成同一件事实的第二个出处。

七、边角情况(逐条怎么处理)

情况 怎么处理 现在有没有
购买失败 finish 掉(不然一直重投)+ 给用户看的红色提示 ⚠️ 只打日志,提示要补
用户取消 finish 掉,安静收场
待处理(Android PENDING) 一个 Spark 都不给,画状态 ⑪,等 RTDN 或下次查询 ❌ 要建
重复交易 主键 / partial unique 撞上 → 回 200 + already:true,不重复加钱 ✅(苹果侧)
恢复购买 触发重投未完成交易;消耗型商店不"恢复",但这是自救出口
退款 苹果 REFUND/REVOKE → 扣回;Google RTDN + Voided Purchases。只追了买家那一侧,收款方那一侧没追 → §十一 ⚠️ 见 §十一
拒付 chargeback 商店按退款处理,走同一条扣回路 同上(§十一)
余额已花光才退款 已经处理好了,而且比我原本要建议的更好:Refund() 扣到 0 为止、不允许负余额,扣不满就在流水 ref 上打 :short 标记(repository.go:128) ✅ 逻辑在;但没人读那个标记 ⚠️ 见下
订阅过期 不适用,没有商店订阅(§1.1)
商品查不到 状态 ⑨/⑩ + Retry。九成是控制台协议/税务没填完,不是代码错
断网 不 finish,留给下次重投
交易中途杀进程 同上 —— 商店会重新吐出来
换设备 Spark 在服务端账号上,换设备登录就有
重装 App 同上;未完成的交易启动时重投
多设备同账号 余额在服务端,但两台同时充值要靠 wallets 行锁 ✅(UPDATE … RETURNING)
用户删号 iap_transactions.user_idON DELETE CASCADE(迁移 000172)—— 删号会连财务台账一起删掉 ✅ 行为确认了;❓ 要不要这样是产品/法务决定,不是技术决定

⚠️ 退款那一条我查到底了,结果值得单独说:扣不满时代码会在流水 ref 上写 :short, 意思是"这笔有坏账"。我 grep 了全仓 —— 写它的只有那一处,读它的一个都没有。

一个没有读者的标记等于没有:坏账会安安静静地攒着,没有任何人或任何面板会开口。 判据(记在这儿给下一个人):写下一个计数 / 日志 / 字段,就要同时指出"谁读它" —— 指不出来就是没做。所以阶段 3 里该顺手加的不是新逻辑,是一个能把 :short 捞出来的地方 (管理台一行、或者一条定期对账),而不是再写一个没人看的标记。


八、测试计划

8.1 两个平台共用的验收标准

每一条都要先看到它红过,再说它绿了(铁律 3):

# 用例 通过标准
T1 正常买一档 余额涨对应数额;iap_transactions 多一行 credited;wallet_ledger 多一行 iap_topup
T2 同一笔重投 回 200 already:true;余额不变;台账不多行
T3 后端故意 500 客户端不 finish;重启后自动重投并到账
T4 沙盒交易打生产后端 拒绝(wrong_env),余额不变
T5 伪造凭证 bad_signature,余额不变
T6 未知 product id unknown_product,余额不变
T7 退款通知 余额扣回;台账 refunded
T8 伪造退款通知(没有正确签名) 拒绝,余额不变
T9 买到一半杀进程 重启后自动补上,不重复
T10 断网买 不丢钱,联网后自愈

8.2 Android 专属

# 用例 通过标准
A1 PENDING 交易 不发 Spark;画状态 ⑪;清算后自动到账
A2 不 consume 就再买同一档 复现一次,确认我们确实 consume 了
A3 Google API 不可用 unconfirmed,不 finish,后续自愈
A4 RTDN 推送签名不对 拒绝
A5 orderId 为空 purchase_token 当幂等键,不重复入账

8.3 iOS 专属

# 用例 通过标准
I1 家庭共享交易(FAMILY_SHARED) 确认要不要给 —— 这是个产品决定,现在代码没判它 ⚠️
I2 quantity > 1 按份数给(verify.go 已实现,要真跑一次)

8.4 坏刀(每道守卫都要验)

  • 把幂等那一步去掉 → T2 必须红
  • 把环境检查去掉 → T4 必须红
  • 把"后端 200 才 finish"改成先 finish → T3 必须红
  • 把 RTDN 签名校验去掉 → T8 必须红

一道没有被坏刀验过的守卫,不算守卫。


九、开工顺序(分阶段,带依赖)

原则:每一阶段结束时,系统都是能用的,不留半截。

阶段 0 —— 先去两个控制台看一眼(半天,只有人能做)

  1. ASC 里那 6 个 product id 到底存不存在?
  2. 付费 App 协议 / 税务 / 银行填完了没有?
  3. Play 开发者账号有没有、商家账号有没有?

⚠️ 这一步的结论会改变后面所有事:如果 ASC 里已经建过 …green.500,那 §1.2 的重锚 就是"建 6 个新的 + 下架 6 个旧的";如果没建过,直接按新表建就行。 这是我看不到的地方,也是唯一必须先有答案才能动的一步。

阶段 1 —— 定价重锚(后端,1 天)

  • [ ] products.go:六档换成 §1.2 那张表,USDCentsPriceMYRSen
  • [ ] 停止把价格字段发给客户端(现在 usd_cents 真的在 payload 里,而两处注释都说它不发)
  • [ ] verify_test.go 那条档位守卫跟着换成马币口径 —— 不许删,它正是重锚时唯一会喊的东西
  • [ ] 图纸 IAP_GREEN_ENERGY.md 同笔改(铁律 31)

依赖:阶段 0 的答案。不依赖 Android。

阶段 2 —— 数据库 + 后端抽象(1 天)

  • [ ] 迁移:§6.2 那几列(取号去 LEDGER.md,立刻 +1 写回)
  • [ ] repository.go:入账时写 platform / purchase_token / currency / price_micros
  • [ ] 把"验凭证"抽成一个接口,苹果是第一个实现 —— 先抽再加,别加完再重构

阶段 3 —— Google 校验(后端,2-3 天)

  • [ ] 服务账号凭据:文件放服务器,.env 指路径,永不进 git
  • [ ] google.go:调 Play Developer API 验 purchase token
  • [ ] POST /v1/iap/google/verify
  • [ ] POST /v1/iap/google/notify + Pub/Sub OIDC 校验(见 §5.2 的警告)
  • [ ] 退款扣回复用现有 Refund()
  • [ ] 假服务器测试:⚠️ 替身必须照抄真 API 的鉴权和错误码, 不然只证明了"我发了个请求"

阶段 4 —— 客户端两平台合流(2 天)

  • [ ] IapService:按平台选 verify 端点;Android 处理 PENDING
  • [ ] 确认 Android 侧真的 consume 了(A2)
  • [ ] 状态 ⑧(已付款待入账)和 ⑪(待处理)两屏新建
  • [ ] 失败要有给用户看的话,不只是日志
  • [ ] golden 图:六档卡 / 三个新状态

阶段 5 —— 购买历史页(1 天,可并行)

  • [ ] spark_history_screen.dart,读已有的 GET /v1/wallet/ledger
  • [ ] 钱包卡加入口
  • [ ] 底部说清"账本 2026-08 起算"

这一阶段不依赖任何商店配置,可以最先做完、最先上线。

阶段 6 —— 沙盒实测(2 天,人要在场)

按 §8 全表跑一遍,两个平台各一遍。

阶段 7 —— 上线

  • [ ] 小企业计划报名(§1.2 那笔账)
  • [ ] 生产环境 AllowSandbox=false 再确认一次
  • [ ] 两个通知端点在生产上各收到一条真通知
  • [ ] 先放一档给内部账号真买一次(真扣钱那种),核对台账 / 流水 / 余额三处一致

依赖图

阶段 0(人) ──┬──→ 阶段 1 ──┐
              └──→ 阶段 2 ──┴──→ 阶段 3 ──→ 阶段 4 ──→ 阶段 6 ──→ 阶段 7
                   阶段 5(独立,随时做)

十、交付清单

10.1 上线前必须逐条打勾

Apple - [ ] 付费 App 协议已生效(税务 + 银行) - [ ] 6 个消耗型商品已建、已过审 - [ ] 价格按马币档位设好 - [ ] Server Notifications V2 指向生产端点,且真收到过一条 - [ ] 沙盒账号可用 - [ ] 小企业计划已报名

Google - [ ] 开发者 + 商家账号就绪 - [ ] release 包已上内部测试轨(商品才建得了) - [ ] 6 个一次性商品已建,id 与 iOS 一致 - [ ] 服务账号凭据在服务器上,不在 git 里 - [ ] RTDN 主题 + 推送端点已通,且真收到过一条 - [ ] 测试账号可用

后端 - [ ] 迁移号取自 LEDGER.md 并已 +1 写回 - [ ] 生产 AllowSandbox = false - [ ] 两个通知端点都验签,坏签名会被拒(T8 / A4 真跑过) - [ ] 凭据不出服务器

产品 - [ ] 六档定价已拍板 - [ ] FAMILY_SHARED 给不给,已决定(I1) - [ ] 删号级联删台账,已确认符合财务留存要求

10.2 这份图纸里我没有替他决定的事

  1. 六档的确切价格点 —— 上表是目标锚点,实际要从两个商店的价格档里挑,人在控制台确认。
  2. ASC / Play 里商品建过没有 —— 决定重锚是"改数字"还是"建新商品下架旧的"。
  3. 家庭共享的交易给不给 Spark —— 现在代码没判,是个产品决定。
  4. 删号时财务台账要不要留 —— 现在是 ON DELETE CASCADE(迁移 000172),一起删掉。行为我核过了,要不要改是法务/财务的决定

十一、退款:钱到底从哪儿追回来(2026-09-22 补,Leong 问出来的)

这一节是 09-22 上午在工作房里一问一答挖出来的,不是设计,是现查。 原来退款只散落在 §七 的两行表格里,而真正的洞不在那两行上。

11.1 一笔退款要追两个地方,不是一个

判据一句话:钱现在在谁手里,就从谁那儿追。

  • 用户还没花掉的那部分 → 从 wallets 扣回。已经在做(Refund())。
  • 用户已经花掉的那部分 → 它没有消失,它去了收款方的账本。→ 今天一分都没追。
  • 我们自己抽的那一份 → 本来就没出去,不用追。

这三份加起来正好是全部 —— 每一颗 Spark 要么还在钱包里、要么在某个创作者账本上、 要么是我们自己留下的。所以两侧一起追不会重复扣,这一点值得写下来,因为它看着像会重复。

走一遍真实数字(用一档最小的 US$0.99 = 500 Spark):

时刻 用户钱包 创作者账本 我们
充值到账 +500 收到 US$0.69(苹果抽 30%)
全额打赏一位音乐创作者 −500 → 0 +250(MusicCreatorPct = 50) 留 250
苹果退款 余额已是 0,扣不到东西 +250 原封不动 ⚠️ 被苹果抽走 US$0.69

结果:我们赔掉 US$0.69,还欠着创作者 250 Spark。 钱进来一次,出去两次。

⚠️ 游戏那一侧一模一样,别只当成音乐的事: shop/repository.go:186 在买道具时同样往 dev_ledger 写开发者那 20%(GameDevPct = 20)。 两条第三方通路都漏。 只有「买 AI 助手」那条是安全的 —— 那笔钱留在我们自己这儿, assistant/ 包里一处 dev_ledger 都没有。

11.2 为什么代码没做 —— 因为它只打开了三张桌子

Refund()(iap/repository.go)整个事务里只碰三张表:

iap_transactions   ← 标 status='refunded'
wallets            ← 扣回余额
wallet_ledger      ← 记一笔负数流水

dev_ledger 不在里面。不是判断写错了,是这条路根本没往那边走。

11.3 条款早就写了,所以这不是一个待拍板的产品问题

这一条最值得先说,因为它把一场"该谁承担"的讨论直接结掉:

  • 开发者协议 §6(deploy/www/developer-terms.html:47)把那 20% 定义成 净收入的分成 —— 原文列明「after app-store commissions, payment processing, refunds, chargebacks, and taxes」;同节 :51 还有一条 Clawback: 欺诈 / 自买自卖 / 操纵交易得来的收入无效,可从以后的分账里扣
  • 创作者条款 §4(creator-terms.html:35)给的是净打赏额的 50%, 并且明文沿用开发者协议 §6 的那套(含 clawback)。

所以「退款之后创作者那一份要不要退回来」我们早就答应过了,答案是要。 今天的差距不是政策,是代码没有执行已经签下去的条款:条款说净额,代码付的是毛额。

11.4 只有"追得回来"才算数 —— 冻结期

追回只在钱还没离开的时候有效。创作者一旦提现拿到银行转账,就没有东西可扣了。

别家怎么解决:TikTok 的提现申请要审最多 15 个自然日才放款 (他们的 Cash Award Withdrawal Terms),而 Rewards Policy 里那句正好对上我们这件事 —— 用户拿到退款时,创作者就那几笔礼物已提走的款项作废; 另有一条允许在合理通知下从 Diamonds 余额里扣。 那 15 天不是官僚流程,它就是让第二条能真正执行的东西

我们这边今天:提现是申请制 + 人工线下转账(developer.go:2550 requestPayout,起提 1000), 所以延迟事实上存在,但它没有写在任何地方,也没有任何代码在执行它。 它靠的是"暂时没人来提" —— 那不是机制。

⚠️ 顺带一提,这也是苹果的退款窗口比我们想的长的原因: 退款可以在购买很久之后才来,所以"当天到账当天可提"在设计上就是不成立的。

11.5 苹果的消费请求(CONSUMPTION_REQUEST)—— 我们没答

用户对消耗型商品发起争议时,苹果会来问我们一份消费数据,给 12 小时verify.go:202 的注释里提到了这个类型,但 handler.goswitch只有 REFUND / REVOKE 两个分支,没有它。

不答的后果不是报错,是苹果在没有我们这一侧材料的情况下裁决,而那通常对我们不利。 这是这一节里最便宜的一项:一个 handler。

11.6 退款被滥用没有任何人会看见

这一条是查的时候顺手撞出来的,而它可能比追回本身更值钱:

iap_transactions.status='refunded' 这个标记,全仓只有一个读者, 就是 repository.go:104 它自己的幂等判断(苹果重发同一条通知时别扣第二次)。 没有任何风控逻辑读它;iapwallet 两个包里也一个封号 / 限权函数都没有

所以今天的链条是通的:买 → 花掉 → 退款 → 再来一次,系统里没有任何一处会出声。 追回解决的是这一笔;能被读到的退款记录解决的是下一笔

对照 TikTok:他们的 Virtual Items Policy 明写,付款失败 / 被退回 / 未付清时, 可以暂停账号或切断虚拟物品的使用权,直到结清 —— 那是他们的兜底。我们没有兜底。

11.7 所以要做的是这几件(带依赖)

# 做什么 放哪一阶段 依赖
R1 Refund() 里补上收款方那一侧:按原始分账反向冲 dev_ledger,余额不够就挂到以后的分账上扣(条款 §6 已授权) 阶段 3
R2 提现加冻结期:请求到放款之间留一段可配置的窗口 阶段 3 要先定几天(见 11.8)
R3 CONSUMPTION_REQUEST 加一个 handler,12 小时内应答 阶段 3
R4 退款记录接进风控:重复退款要能被查出来、能出声 阶段 3
R5 :short 找一个读者(管理台一行 / 定期对账)—— §七 已经记了这条,这里只是并进同一批 阶段 3
R6 Android 的退款路(RTDN + Voided Purchases)和 Android 计费同一批做,不要放到后面 阶段 4 Android 计费

⚠️ R6 的理由单独说:退款永远是最后才补的那一半,而在 Android 上它是唯一一条会 安静赔钱的路 —— 没有 RTDN,一笔安卓退款就是把 Spark 永远留在钱包里,而且没人会知道。

11.8 这一节里我没有替他决定的事

  1. 冻结期几天。 TikTok 是最多 15 个自然日。我们要多少天,取决于愿意让创作者等多久, 这是产品取舍不是技术取舍。
  2. 重复退款要不要停权。 TikTok 的条款留了这个口子。我们今天没有,要不要有是产品决定。
  3. 创作者本人没做错时那笔钱怎么算。 条款允许从以后的分账里扣; 但"扣"和"我们自己吃下去"之间还有一档——小额以下不追。这条线画在哪里没人定过。

11.9 现在的暴露面:零

生产库 iap_transactions 零行 —— 从来没有人买过,所以也从来没有人退过款, 上面每一条都一次都没被真正跑过

这句话有两面:一面是不紧急;另一面是现在改最便宜 —— 没有历史数据要迁移,没有已经付出去的钱要追,也没有任何用户会察觉。


附:这份图纸和现有文档的关系

  • docs/IAP_GREEN_ENERGY.md —— 苹果那一侧的设计取向,仍然有效;本文是它的两平台续篇
  • docs/GREEN_ENERGY_ASBUILT.md —— as-built;⚠️ 里面几处引用的行号已经漂了,本文用的是现查的
  • docs/wallet.md —— 钱包全景 + 拍板账本
  • backend/migrations/LEDGER.md —— 取迁移号的地方

真要动手时,以代码为准,不以任何一份文档为准 —— 包括这一份。