| 档案 | |
|---|---|
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/,后端零命中(只有图纸里提到过"没做")。具体缺:
- Google Play Billing 的服务端校验(需要 Google Play Developer API + 服务账号凭据)
- Real-time Developer Notifications(RTDN,走 Pub/Sub)—— 苹果那一侧的对应物已经有了
iap_transactions没有platform列,主键是苹果的交易号- Play Console 里六个商品还不存在
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 抬头写着,两个商店审核都会看):
- 价格必须显示商店返回的本地化字符串,写死会被拒,而且外区用户看到错币种
- 底部必须写清「不可退款 / 不可转让 / 不可提现」+ 条款 / 隐私链接
- 「恢复购买」不是摆设 —— 消耗型商品商店不提供恢复,但这个动作会把 未完成的交易重新吐进购买流,是用户「钱扣了没到账」时的自救出口
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 都不许给**
这张图的核心只有一句:credited 和 finish() 之间不许有任何可能失败的步骤,
而 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 | ||
|---|---|---|
| 凭证 | 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_id 是 ON 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 —— 先去两个控制台看一眼(半天,只有人能做)
- ASC 里那 6 个 product id 到底存不存在?
- 付费 App 协议 / 税务 / 银行填完了没有?
- Play 开发者账号有没有、商家账号有没有?
⚠️ 这一步的结论会改变后面所有事:如果 ASC 里已经建过 …green.500,那 §1.2 的重锚
就是"建 6 个新的 + 下架 6 个旧的";如果没建过,直接按新表建就行。
这是我看不到的地方,也是唯一必须先有答案才能动的一步。
阶段 1 —— 定价重锚(后端,1 天)
- [ ]
products.go:六档换成 §1.2 那张表,USDCents→PriceMYRSen - [ ] 停止把价格字段发给客户端(现在
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 这份图纸里我没有替他决定的事
- 六档的确切价格点 —— 上表是目标锚点,实际要从两个商店的价格档里挑,人在控制台确认。
- ASC / Play 里商品建过没有 —— 决定重锚是"改数字"还是"建新商品下架旧的"。
- 家庭共享的交易给不给 Spark —— 现在代码没判,是个产品决定。
- 删号时财务台账要不要留 —— 现在是
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.go 的 switch 里
只有 REFUND / REVOKE 两个分支,没有它。
不答的后果不是报错,是苹果在没有我们这一侧材料的情况下裁决,而那通常对我们不利。 这是这一节里最便宜的一项:一个 handler。
11.6 退款被滥用没有任何人会看见
这一条是查的时候顺手撞出来的,而它可能比追回本身更值钱:
iap_transactions.status='refunded' 这个标记,全仓只有一个读者,
就是 repository.go:104 它自己的幂等判断(苹果重发同一条通知时别扣第二次)。
没有任何风控逻辑读它;iap 和 wallet 两个包里也一个封号 / 限权函数都没有。
所以今天的链条是通的:买 → 花掉 → 退款 → 再来一次,系统里没有任何一处会出声。 追回解决的是这一笔;能被读到的退款记录解决的是下一笔。
对照 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 这一节里我没有替他决定的事
- 冻结期几天。 TikTok 是最多 15 个自然日。我们要多少天,取决于愿意让创作者等多久, 这是产品取舍不是技术取舍。
- 重复退款要不要停权。 TikTok 的条款留了这个口子。我们今天没有,要不要有是产品决定。
- 创作者本人没做错时那笔钱怎么算。 条款允许从以后的分账里扣; 但"扣"和"我们自己吃下去"之间还有一档——小额以下不追。这条线画在哪里没人定过。
11.9 现在的暴露面:零
生产库 iap_transactions 零行 —— 从来没有人买过,所以也从来没有人退过款,
上面每一条都一次都没被真正跑过。
这句话有两面:一面是不紧急;另一面是现在改最便宜 —— 没有历史数据要迁移,没有已经付出去的钱要追,也没有任何用户会察觉。
附:这份图纸和现有文档的关系
docs/IAP_GREEN_ENERGY.md—— 苹果那一侧的设计取向,仍然有效;本文是它的两平台续篇docs/GREEN_ENERGY_ASBUILT.md—— as-built;⚠️ 里面几处引用的行号已经漂了,本文用的是现查的docs/wallet.md—— 钱包全景 + 拍板账本backend/migrations/LEDGER.md—— 取迁移号的地方
真要动手时,以代码为准,不以任何一份文档为准 —— 包括这一份。