📚 wallet · 模组知识

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

版本 ad1bdbb5
2026-09-09 00:34
档案
module wallet
summary 钱包:一个 App 里有两个「钱包」,两套钱各走各的账,中间只有一个单向口。用户侧是绿能量 —— wallets 一行存余额、wallet_ledger 记流水(账本 2026-08 才起算,更早的余额变动没有流水);进来只有四条(首次由商城 / 签到 Ensure 开户时白送 1000 —— 先充值的人由内购零余额开户、以后不再得那 1000;苹果内购;每日签到 +1;退款负向),出去只有三条(商城买道具、音乐打赏、买 AI 助手),不过期、无上限,红能量建了列但全库无写入。看广告不发绿能量。开发者侧是 dev_ledger 纯流水、余额 = SUM,由商城消费和音乐打赏当场同事务入账、广告要管理员手动录一期才分(苹果充值本身不给任何开发者入账)。分成比例只有一个出处 revshare 包:游戏商城和广告都 20%、音乐打赏 50%;零头按路径不同 —— 商城和广告的整除余数留平台,只有音乐打赏经 CreatorSide 把余数给创作者。提现没有接任何支付通道 —— 走完九道闸(approved 开发者 / 起提 1000 绿 / 收款方式必填 / eKYC 通过 / 收款人姓名等于证件姓名 / advisory lock / 一笔 pending / 余额够 / 唯一索引)之后只写一行 pending(申请这一侧有 advisory lock + 唯一索引防并发),管理员线下汇完款回来点「已付」,那一步是一句不带状态条件、不看影响行数的 UPDATE,结算这一侧没有防并发;payouts 行数 2026-09-08 那天核过是 0,今天未核。内购只有苹果,StoreKit 2 JWS 离线验签、内嵌 Apple Root CA G3,幂等键是 transaction_id 主键、先插台账再加钱,沙盒默认不接受;绿能量到账数只认服务端档位表。eKYC 照片走预签名直传 R2 只存 key、证件号 AES-GCM 加密(没配密钥就 fail-closed)、发看图签名地址前留一次痕、通过留 7 年被拒 90 天到期先删对象再抹密文与生日、full_name 常规到期不抹(提现比对要用)但删号会抹。经济口径待确认、不止一项:一绿的入口定价(美区参考档 $0.99 = 500 绿 ≈ $0.00198)和出口结算(界面 100 绿 = 1 MYR、代码 ÷100、条款 $0.01)没有统一口径,实际收付币种没定,音乐 50% 也悬着;保留期已拍「keep the default」,法律依据待律师。
updated 2026-09-09
verified_ref 6fcfe8e6ed2f94b4f63b60e801d7bfcaa9b3815a
source_ref [backend/internal/domain/{wallet,iap,shop,revshare,kyc,checkin}/, backend/internal/domain/developer/{developer,adrevenue}.go, backend/internal/domain/games/ad.go, backend/internal/domain/music/{split,handler}.go, backend/internal/domain/assistant/assistant.go, backend/migrations/000130 000132 000166 000167 000172 000298 000301 000307 000357 000358 000361 000363, app/lib/screens/{topup,game_shop_sheet,developer_center,game_revenue,music_revenue,kyc_*,daily_checkin,assistant_activate}_screen.dart, app/lib/services/{iap_service,ad_service}.dart, app/lib/api/{revshare_api,kyc_api}.dart, app/lib/util/green_money.dart, deploy/www/developer-terms.html]
status current
review_after 2026-12-08
owner nova
supersedes [GREEN_ENERGY_ASBUILT.md(08-10 as-built,本文并入,汇率重定的日期它写 08-09 有误)、IAP_GREEN_ENERGY.md(设计图纸,四处已知与代码不符)、AD_MONETIZATION.md(07-15,真广告位那段运营事实已变)、CREATOR_REVENUE_UI_AUDIT.md(08-21 UI 审查,基线 c7f53469,列的 ❌ 多条已被 08-24~09-08 十几刀推翻)、hoopstudio/hoopstudio-wallet-prd.md(09-07 v1.1,这批里最可靠的一份,本文沿用它的两套钱框架);EKYC_LEGAL_FACT_SHEET_2026-09-08.md 和 EKYC_PRIVACY_RETENTION_AUDIT_2026-09-08.md 不作废(法务面留在那两份),本文只取工程事实,且按前者更正后者;WARMTH_ECONOMY.md 不并入 —— 温度是关系分不是货币,蓝图划给 castle]

这份怎么读:每一节先讲「它解决什么、用户看到什么」,再讲「为什么这样做、钱在哪一步动、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里。 出处标记:[code@6fcfe8e6 文件:行] = 那棵树上的实现;[prod@日期,谁核] = 生产机当天核过;[decision@日期,谁] = 拍板过的决定。代码证明「那一版怎么做的」,证明不了「线上是这样」和「应该是这样」。 本文没有连生产库、没有跑过一笔真交易:凡是「线上现在多少行」这类话,只标注旧文档核过的那一天,不冒充今天。 边界:游戏本体、上架审批归 games;音乐发行与播放归 music;城堡与温度归 castle(温度是关系分,不是货币,和绿能量之间没有任何兑换通道,双向 grep 零命中);开发者平台的非钱部分(建游戏、传包、审核)归 developer 那条线,本文只写它里面动钱的那一半。

0. 给接手的人:三分钟读懂钱包

先认六个词:绿能量(green) = 用户侧充值币,wallets.green 一行一个数;红能量(red) = 建了列、全库无任何写入,只在读取和商品 price_red 里出现,当它不存在;用户账本 = wallet_ledger,流水,2026-08 才起算;开发者账本 = dev_ledger,纯流水没有余额行,余额 = SUM(green);分成 = revshare 包里两个常量,别处全是转手;提现(payout) = 一行状态 + 一笔冻结流水,钱是人线下汇的

它保证到什么程度:

  1. 两个「钱包」不是一个东西,中间只有一个单向口。 用户拿真钱买绿能量、花绿能量;开发者/创作者赚的是 dev_ledger 里的绿,能换回真钱(线下汇款)。同一个人两边的余额互不相通,没有任何一条路把 wallets.green 转进 dev_ledger。唯一的口是别人花掉的绿能量,当场按比例记一笔进你的 dev_ledger。所以「绿能量不能提现」(内购页那句文案)和「这条线是闭环、最后能提现」(旧图纸)两句都对,说的是两个钱包。
  2. 动钱的主路径都在一个事务里,但有三个例外、失败分三种。 商城扣款、打赏、签到、内购入账、助手续期、提现申请、提现结算、广告分账都是 Begin / defer Rollback / Commit 一次走完。例外一:开户白送有两条调用路,只有一条在事务里 —— Ensure 只是接受一个 Conn:shop.GetWalletcheckin.Status 传的是连接池,开户 INSERT 1000 和 grant 流水分开提交,后一步失败留下「有 1000、无流水」,重试因钱包已在也不补;shop.Buycheckin.CheckIn 传的是 tx,开户和 grant 受外层事务保护、失败一起回滚。先充值的人由内购 INSERT green=0 开户,以后不再得那 1000,助手也不补送。例外二:提现结算没有防并发(第 6 条)。例外三:打赏的分账输入在事务外读(第 3 条)。失败要分三种:事务内失败 = 明确回滚;Commit 通信错误 = 结果未知;提交后回读 / 回包失败 = 钱已动(助手续期 Commit 后再查 Status,查失败时钱已扣、订阅已改;签到 Commit 后再读 history,读失败时签到和 +1 已提交)。所以「新用户累计流水必等于余额」不成立。
  3. 重复请求会怎样,一条条不一样:内购有 transaction_id 主键、商城有 inventory 主键 + 已拥有早退、签到有 (user_id, day) 主键、提现有 advisory lock + 一笔 pending 的唯一索引、广告分账有 (period_from, period_to) 唯一索引、AI 助手有 Idempotency-Key 或 20 秒窗;用户消费请求里只有音乐打赏没有幂等键 —— 重复 POST 就是重复扣款重复入账,防线只有那一行行锁;另有两处防重不足独立算,别被这个「只有」吞掉:提现结算无并发 / 防重(第 6 条),广告分账只挡完全相同区间(第 5 条)。两张账本都有 id 主键,但没有业务幂等唯一约束;助手续期的幂等是查 wallet_ledger.ref,不全靠业务表。打赏虽然写钱同事务,合作者名单和分成锁定状态是事务外用 h.pool 读的,查询报错时前者回空、后者回未锁定,仍可能按回落分账 —— 写入原子不等于分账输入可靠。
  4. 内购只有苹果,没有 Google Play。 验签是离线的(StoreKit 2 JWS,内嵌 Apple Root CA G3),到账数额只认服务端那张六档表,客户端报多少一律不信。沙盒收据默认不接受(IAP_ALLOW_SANDBOX 不等于 "true" 就拒)。验签核的是签名和交易字段;「没重复」是下一步 Credittransaction_id 台账时才判的,两步分开。两步都不证明它属于当前 HOOP 账号:签名交易结构体里解出的 AppAccountToken 没和登录 uid 比对(台账没有这一列,只留 raw_jws 原文),入账归发请求的人。App 侧后端回错一律 catch 不 finish、不按结果码分流;500 / 断网不能断言余额没变,可能已提交。
  5. 看广告不发绿能量。 广告上报端点只往 ad_events 写一行事实,一个字节都不碰 wallets;游戏内的复活/道具是 H5 游戏自己发的,App 侧拿到 completed 就直接回给游戏。广告的钱是另一条路:管理员手动录一期总收入,按各游戏 completed 的权重分池(权重总和含平台自营游戏,轮到它们那份时跳过、留平台)—— 而这条路默认是关着的(没接 AdMob SSV 之前,「看完」还是客户端自报)。
  6. 提现没有真把钱打出去。 全仓搜遍支付通道关键词零命中;这条路的最后一步是 UPDATE payouts SET status='paid'。真正的钱由管理员线下汇,汇完回来点一下;payouts.method / note 能存收款方式和回单备注,但没有支付通道回执可核验。申请那一侧有 advisory lock + 唯一索引;结算那一侧 SELECT pending 不加 FOR UPDATEUPDATE 只按 id 不带状态条件也不看影响行数 —— 同一单并发拒绝可以两次读到 pending、两次补正流水,paid / rejected 并发会互相覆盖,事务挡不住这种交错。
  7. eKYC 是提现的前置,也是全模组最敏感的数据。 照片不进库,预签名直传对象存储只存 key;证件号 AES-GCM 加密,没配密钥就整笔失败绝不明文落库;发看图签名地址前留一次痕、留痕写不下来就不给地址;通过的留 7 年、被拒和长期 pending 的 90 天,到期先删对象再抹密文与生日;full_name 常规到期不抹(抹了提现的姓名比对就永远过不去),删号的 Eraser 会抹。

当前已核实没做的(§4 全表,先说五条):没有 Google Play 内购;没有任何支付通道(提现是线下汇款);没有人工调账端点(KindAdmin 常量定义了但全库无写入方);GET /v1/wallet/ledger 后端在、App 侧零调用方(用户看不到自己的绿能量流水);预签名上传后从未提交的 KYC 孤儿对象没有清理路径。

经济口径待确认,不止一项:一绿的入口定价(美区参考档 $0.99 = 500 绿 ≈ $0.00198,各地区档位不是统一面值)和出口结算(界面 100 绿 = 1 MYR、提现代码写死 ÷100、条款 $0.01)没有统一口径;实际收付币种没定;音乐 50% 从 08-05 挂到今天。历史上有过定价和显示的决定(§7),但入口和出口从没被一句话拍成一个数。eKYC 保留期已拍「keep the default」,法律依据待律师。详见 §4 和 §6。

出处:两套钱 [code@6fcfe8e6 backend/migrations/000130_wallet_shop.up.sql:5-10] [code@6fcfe8e6 backend/migrations/000132_dev_revenue.up.sql:4-26];账本起算点 [code@6fcfe8e6 backend/internal/domain/wallet/ledger.go:6-9];红能量无写入 [code@6fcfe8e6 backend/migrations/000130_wallet_shop.up.sql:8];广告不发绿能量 [code@6fcfe8e6 backend/internal/domain/games/ad.go:197-200];提现最后一步 [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2369-2371]。

1. 两段流程的一生

1.1 一块钱从用户的口袋走到开发者的口袋

你看到的:你在充值页买 500 绿(苹果收 $0.99),回到游戏商城花 500 绿买一支飞镖;做这支飞镖的开发者过一会儿在开发者中心看到自己多了 100 绿(20%);攒够 1000 绿他点提现、填收款方式和姓名,状态变成「处理中」;某天钱到他银行账户,那笔在后台被标成「已付」。

每一步机制上发生了什么:

  1. 充值:App 启动时就订阅了购买流(不是等你进充值页),点买 → StoreKit → 拿 JWS 收据 → POST /v1/iap/apple/verify。后端离线验签:x5c 证书链验到内嵌的 Apple Root CA G3 → 叶子证书时效 → ES256 裸 r‖s 验签,再查 bundleID、沙盒闸、RevocationDate != 0 拒、product 白名单,按 quantity 乘。到账多少只查服务端那张六档表(500 / 1600 / 3400 / 6500 / 14000 / 34000)。一个事务里:先插 iap_transactions 台账(主键 = transaction_id)、再加 wallets.green、再记一行 wallet_ledger。台账抢在加钱之前,是幂等的全部秘密:第二次上报同一张收据撞 23505 主键冲突,回 ErrAlreadyCredited,handler 照常回 200 但 green_added: 0。App 拿到 200 才 completePurchase;后端回错(含验签 400)App 一律 catch 不 finish,不按结果码分流。签名交易结构体里解出的 AppAccountToken 不与登录 uid 比对(台账没有独立列,raw_jws 保留原文),入账归当前请求的 uid。
  2. 消费:进游戏商城 → GET /v1/games/{id}/shop 回包里带 wallet.green → 点买 → 一个事务:SELECT ... FOR UPDATE 锁钱包行 → 用服务器上的价 → 扣 wallets.green → 插 inventory(主键 (user_id, game, sku),ON CONFLICT DO NOTHING)→ 记 wallet_ledger同一个事务里给开发者记分成。余额不够回 402 insufficient,App 弹「去充值」。已经拥有的直接返回当前钱包,不重复扣。
  3. 分成入账:share := price * 20 / 100,零头归平台;平台自营游戏(games.dev_id 为空)不分成,开发者买自己的游戏也不分。写的是 dev_ledger 一行正数 —— 那张表没有余额列,开发者的余额就是这张表的 SUM(green)。音乐打赏走的是同一个形状:打赏那一刻同事务扣付款人的 wallets.green、按 50% 给创作者那一侧记账,创作者那一侧用 CreatorSide(余数归创作者)再按 splitShares 在主创和已接受的合作者之间分第二层;合作者名单和分成锁定状态是事务外用 h.pool 读的,查询报错回空 / 回未锁定,仍可能按回落分。
  4. 提现申请:POST /v1/dev/payout。九道闸按顺序:approved 开发者 → 金额 ≥ 1000 绿 → 收款方式非空 → kyc.Gate 必须 approved,否则 403 kyc_required收款人姓名折叠大小写空白后必须等于证件姓名 → 事务级 advisory lock(命名空间 8601)→ 已有 pending 拒 409 → 余额不足拒 → 插 payouts(pending)+ 插 dev_ledger 一行负数把额度冻结扣掉 → Commit;数据库最后还有一道部分唯一索引兜底(一个 dev 只能有一笔 pending)。
  5. 提现结算:管理员在 GET /v1/dev/admin/payouts 看待付列表,线下汇款,回来 POST /v1/dev/admin/payout 点「已付」→ 事务里 SELECT ... WHERE id=$1 AND status='pending'(没有 FOR UPDATE)→ UPDATE payouts SET status=$2, settled_at=now() WHERE id=$1(不带 status 条件、不看影响行数)→ Commit → 回 {"ok":true}没有第二步,也没有防并发。 点「已付」不产生任何账本行(钱在申请那一刻就冻结扣了);点「拒绝」才补一行正数回去。

这条路上每一步断了留下什么:

断在哪 留下什么 用户/开发者看到什么
苹果侧取消 什么都没有(App 清 pending 并 finish) 界面什么都不说,只是转圈停掉
拿到收据但后端 500 / 断网 交易不 finish,留在苹果侧;下次启动订阅时重投;后端可能已提交(错误可能发生在 Commit 之后) 界面同样什么都不说;余额变没变不能断言 —— 已提交的话重投撞主键回 200 且 green_added: 0,没提交的话重投可能首次到账
后端验签失败 / 不在白名单 无台账无余额 App 一律 catch 不 finish、不按结果码分流;下次启动重投再拒
后端事务中途失败 全回滚,连台账都不留(所以能重试) 余额没变
同一张收据重复上报 台账在、余额只加过一次 200 且 green_added: 0
苹果退款通知 扣回实际能扣的数(扣到 0 为止不允许负余额),流水记的是实际扣回额、与余额一致;扣不满只在 ref 上打 :short 标记,差额不存,要用台账原额减实际扣回额算 余额少了;台账 status='refunded' 二次幂等
退款处理本身失败 仍回 200(避免苹果重发风暴),只记日志 没人知道,靠人工查台账补
开户白送在池路径(GetWallet / 签到 Status)记流水失败 钱包已有 1000、无 grant 流水;重试不补(Buy / CheckIn 走 tx 的那两条会一起回滚) 余额对、流水少一行
签到 Commit 后读 history 失败 签到和 +1 已提交,接口回错 界面报错但已生效
商城扣款中途失败 全回滚 余额没变、道具没有
打赏重复提交 重复扣、重复入账(无幂等键) 钱真的少两次
打赏时合作者 / 锁定状态查询报错 扣款照走,按「无合作者 / 未锁定」回落分账 钱扣了,分法可能不是定好的
助手续期 Commit 后回读失败 钱已扣、订阅已改,接口回错 界面报错但已生效
提现申请中途失败 全回滚,没有半张单子 余额没变
管理员点「已付」失败 事务内失败 = 回滚、单子还在 pending;Commit 后丢响应 = 已 paid;两人同时点 = 状态互相覆盖、拒绝可补两次正数流水 待付列表里可能还在,也可能已不在
钱汇丢了 / 汇错人 库里已是 paid,method / note 里有管理员填的收款方式和备注,没有支付通道回执可核验 靠银行回单和 note

出处:验签 [code@6fcfe8e6 backend/internal/domain/iap/verify.go:110-133] [code@6fcfe8e6 backend/internal/domain/iap/verify.go:177-196];档位表 [code@6fcfe8e6 backend/internal/domain/iap/products.go:21-28];入账事务与幂等 [code@6fcfe8e6 backend/internal/domain/iap/repository.go:29-64];重复上报回 200 [code@6fcfe8e6 backend/internal/domain/iap/handler.go:75-81];退款 [code@6fcfe8e6 backend/internal/domain/iap/repository.go:104-128] [code@6fcfe8e6 backend/internal/domain/iap/handler.go:117-125];商城事务 [code@6fcfe8e6 backend/internal/domain/shop/repository.go:117-190];分成入账 [code@6fcfe8e6 backend/internal/domain/shop/repository.go:174-185];打赏 [code@6fcfe8e6 backend/internal/domain/music/handler.go:2044-2098];提现九道闸 [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2178-2283];唯一索引 [code@6fcfe8e6 backend/migrations/000301_payout_no_double.up.sql:18-19];结算 [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2329-2380];App 侧内购流 [code@6fcfe8e6 app/lib/services/iap_service.dart:99-171]。

1.2 一次 eKYC 的一生:从拍照到过期被抹掉

你看到的:提现前被拦下来说「请先完成身份验证」→ 四步向导:选国籍和证件类型 → 拍证件(MyKad 正反两张、护照一张)→ 扫脸自拍 → 复核提交 → 等审核 → 通过之后才能提现。

机制:

  1. 传图:App 先 POST /v1/kyc/upload-url 拿一个 10 分钟有效的预签名 PUT 地址和一个 key(形如 kyc/{uid}/{时间戳}-{front|back|selfie}.jpg),然后 http.put 直传对象存储,字节不过 HOOP 后端
  2. 提交:POST /v1/kyc/submit 带的是后端给的那几个 key;后端校验 key 前缀必须是 kyc/{你自己的uid}/ —— 防的是拿别人的 key 冒名。证件号用 AES-GCM 加密后存 doc_no_enc,另存后四位明文供界面显示;密钥来自 KYC_ENC_KEY 经 SHA-256 拉成 32 字节,没配密钥就 fail-closed:整笔提交失败,绝不明文落库
  3. 一人多条:重交是新插一条,不 UPDATE 覆盖;「当前状态」= 最新那一条。重交不是带 replace 就够:insertSubmission 在每用户事务锁内拒绝已有 pending、查滚动 24 小时的提交额度;新的 pending 成为最新记录,Gate 会暂时挡提现;库查询失败回 500,不是「没做 KYC」。
  4. 审核:管理员在 GET /v1/kyc/admin/pending 看列表,GET /v1/kyc/admin/detail全库唯一解密全号的地方;看照片走 GET /v1/kyc/photo 签一个 5 分钟短命地址,发地址前留一次痕(写 kyc_photo_views,本人或管理员都记),留痕写不下来就不给地址;拿到短链后 5 分钟内重复访问不经过这个端点,所以留痕记的是「签了几次地址」,不是「照片字节被读了几次」。
  5. 到期:PhotoPurger 每 6 小时一轮、每轮 200 行(启动先扫一次)。通过的留 7 年,被拒的和一直没人审的 90 天。清理语义是:先把对象存储里三张照片真删干净,全删成功才写 SQL 抹掉 doc_no_encdob,行留着当墓碑。对象删除不能随 SQL 回滚:部分已删、key 还在的下一轮重试;每轮 200 行、删对象或 SQL 失败都会把到期往后推 —— 「到期就没了」只是正常运行无积压时的预期,不是硬上限。full_name 常规到期不抹 —— 提现的姓名比对读的就是它,抹成空串等于让人从第 7 年起永远提不了现;删号那条路会抹(第 6 步)。
  6. 删号:Eraser 挂在删号清理钩子上,抹 full_name / doc_no_enc / doc_no_last4 / country / dob / nationality + 三张照片。它必须存在,是因为 user_id 上那条 ON DELETE CASCADE 永远不会触发 —— 删号那条路只抹 users 的字段,不删行。

断点:预签名地址过期(10 分钟)= 直传 403,向导停在那一步、已拍的照片还在内存里不会丢;直传成功但从此不提交 = 对象留在存储里,due() 只扫 kyc_submissions 的行,没有任何路径回收它(§4);提交失败按后端 code 逐字段说人话;审核期间照片一直在,拒了之后 90 天清。原生相机把 JPEG 落到手机磁盘,Dart 侧只读字节从不删,原生侧清不清 —— 未核

出处:预签名与前缀校验 [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:383-385] [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:463-469];加密 fail-closed [code@6fcfe8e6 backend/internal/domain/kyc/crypto.go:20-57];一人多条 [code@6fcfe8e6 backend/migrations/000298_kyc_submissions.up.sql:6-8];看图留痕 [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:598-620];清理器 [code@6fcfe8e6 backend/internal/domain/kyc/purge.go:78-167];为什么不抹名字 [code@6fcfe8e6 backend/internal/domain/kyc/purge.go:47-58];删号抹除 [code@6fcfe8e6 backend/internal/domain/kyc/erase.go:12-34];App 四步向导 [code@6fcfe8e6 app/lib/screens/kyc_wizard_screen.dart:155] [code@6fcfe8e6 app/lib/screens/kyc_wizard_screen.dart:421-447];直传 [code@6fcfe8e6 app/lib/api/kyc_api.dart:42-109]。

2. 用户能看到的能力(怎么用 / 限制 / 入口)

能力 限制与规则 入口(谁 push 它)
充值绿能量 六档 500 / 1600 / 3400 / 6500 / 14000 / 34000;价格由 StoreKit 本地化实时给,App 里没写死;只有苹果 设置「绿能量充值」;打赏余额不足的 SnackBar;打赏面板底部;游戏商城余额不足;AI 助手激活页余额不够
恢复购买 手动重投未 finish 的交易;平时靠启动订阅自动重投 充值页顶部
每日签到 +1 绿 一天一次,「一天」按 UTC+8 写死切,不读服务器时区 签到页
游戏商城买道具 服务器权威价、只扣绿(price_red 从不参与)、没有库存概念、已拥有不重复扣 游戏壳底栏「商店」;H5 游戏自己调 shopOpen
音乐打赏 单笔 10 – 100000 绿;无幂等键 音乐页打赏面板
买 / 续 AI 助手 三档 400 / 1200 / 12000 绿;Idempotency-Key 或 20 秒窗防重 通讯录里那一行
看自己的绿能量流水 后端有 GET /v1/wallet/ledger,App 零调用方 —— 用户看不到
看创作者余额与收入 余额 = dev_ledger 之和;分成比例文案来自 GET /v1/revshare(这个端点不需要登录) 个人中心 → 钱包页;侧栏 Wallet;音乐收入页底部
游戏收入明细 只统计 kind ∈ {sale, ad};整屏一个接口都不调,数据由钱包页传进去;没有任何按钮 HoopStudio 的 Income 格
音乐收入明细 GET /v1/music/revenue;三态分开,失败画重试绝不画 0 音乐工作室 Tip (MYR) 格
申请提现 起提 1000 绿;必须 approved 开发者 + eKYC 通过 + 收款人姓名等于证件姓名;同时只能有一笔 pending 钱包页那一颗 wallet_withdraw(唯一真发请求的)
Transfer(转账) 一句 coming soon 的 toast,没有接口 钱包页
身份验证 eKYC 四步;MyKad 正反两张、护照一张;有自建相机(带活体结论),起不来回落系统相机(活体老实标 false) 设置 → 身份验证;个人中心头像旁的 (!);钱包页提现被拦时
看广告 AdMob 激励广告;当前跑的是测试广告位;发奖由 H5 游戏自己做,后端只收一条事实上报 只有 H5 游戏通过 ads.showRewarded 桥调
审核台(管理员) KYC 审批、待付提现、批开发者/游戏、录广告收入;判据一律是 ADMIN_UIDS 环境变量里的 uid 名单 设置里 if (_isAdmin) 那一行;钱包页管理员区

几条界面上的真相,写在这儿免得每次重查:

  • 充值页那张余额卡冷启动永远画 :它读的是 IapService.balanceGreen,而这个字段只在内购成功入账时才被写;refresh() 只拉档位表和 StoreKit 价格,不拉余额
  • 余额没有一处是本地缓存优先的:商城、签到、助手、钱包、音乐收入,每一处都是进屏现拉,失败就是失败(音乐收入页和钱包页明确不画 0,创作者看板那一处例外,拉不到落回 0 但外层用 != null 挡了一道)。
  • 个人中心的 Withdraw / Transfer 两颗键不发任何请求,共用一个 onOpenWallet,只是跳到钱包页。
  • 分成比例在 App 里有一份写死的兜底 {sale:20, ad:20, tip:50},只有开发者中心 _load() 调过一次 RevShareApi.refresh();没进过开发者中心的人在音乐工作室看到的就是这份兜底。兜底存在的理由是「第一帧不能空着」,不是第二个出处。

出处:充值入口 [code@6fcfe8e6 app/lib/screens/account_screen.dart:558] [code@6fcfe8e6 app/lib/screens/music_screen.dart:960] [code@6fcfe8e6 app/lib/screens/game_shop_sheet.dart:133];余额卡 [code@6fcfe8e6 app/lib/screens/topup_screen.dart:107-125] [code@6fcfe8e6 app/lib/services/iap_service.dart:71-96];签到时区 [code@6fcfe8e6 backend/internal/domain/checkin/repository.go:27-30];打赏限额 [code@6fcfe8e6 backend/internal/domain/music/handler.go:2025];助手档位 [code@6fcfe8e6 backend/internal/domain/assistant/assistant.go:89-91];两个死接口 [code@6fcfe8e6 app/lib/api/api_client.dart:3917] [code@6fcfe8e6 app/lib/api/api_client.dart:4220];提现键 [code@6fcfe8e6 app/lib/screens/developer_center_screen.dart:778-785];Transfer toast [code@6fcfe8e6 app/lib/screens/developer_center_screen.dart:756-758];两颗假键 [code@6fcfe8e6 app/lib/widgets/creator_dashboard.dart:127-166];比例兜底 [code@6fcfe8e6 app/lib/api/revshare_api.dart:24] [code@6fcfe8e6 app/lib/screens/developer_center_screen.dart:507];游戏收入页无接口 [code@6fcfe8e6 app/lib/screens/game_revenue_screen.dart:39-68];/v1/revshare 不鉴权 [code@6fcfe8e6 backend/internal/domain/revshare/handler.go:27-29]。

3. 内部怎么运作

3.1 两个「钱包」,两套账,一个单向口

wallets余额行(green / red,主键 user_id),wallet_ledger流水(正进负出,带记账后余额快照 balanceref)。余额是存着的,不是算出来的 —— 流水只是并行记录,而且账本 2026-08(迁移 000172)才起算,更早的余额变动没有流水行,所以「逐行累加应等于余额」这个等式只对账本上线后、且开户那行 grant 流水没丢的用户成立(§3.2)。wallet_ledgerid 主键、没有业务幂等唯一约束,只有 (user_id, created_at DESC) 普通索引;kind 列也没有 CHECK 约束,七个种类只是常量。

dev_ledger 反过来:纯流水,没有余额行,余额 = SELECT SUM(green)。它同样只有 id 主键、没有业务唯一约束。payouts 是状态行,pending / paid / rejected;申请侧的并发防线是 advisory lock + 那条部分唯一索引,结算侧没有

单向口只有一个:别人花绿能量买你的东西 / 打赏你,当场同事务在你的 dev_ledger 记一笔。反过来没有任何路径 —— wallets.green 转不进 dev_ledger,dev_ledger 也不会变回 wallets.green(提现是变成真钱,不是变回绿能量)。

出处:[code@6fcfe8e6 backend/migrations/000130_wallet_shop.up.sql:5-21] [code@6fcfe8e6 backend/migrations/000172_iap_green.up.sql:29-38] [code@6fcfe8e6 backend/internal/domain/wallet/ledger.go:6-27] [code@6fcfe8e6 backend/migrations/000132_dev_revenue.up.sql:4-26] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:1958-1961] [code@6fcfe8e6 backend/migrations/000307_dev_ledger_line.up.sql:28-40]。

3.2 绿能量:四进三出,不过期、无上限

进来的口,穷举四条:开户白送 1000(只在首次由商城 / 签到那条 Ensure 建钱包时;Ensure 只接受 Conn —— GetWallet / 签到 Status 传连接池,INSERT 1000 和 grant 流水分开提交、流水失败不回滚也不补;Buy / CheckIn 传 tx,一起回滚;先充值的人由内购 INSERT ... green=0 开户,以后不再得 1000,助手也不补送)、苹果内购、每日签到 +1、退款(负向)。出去的口,穷举三条:商城买道具、音乐打赏、买 / 续 AI 助手。

没有过期机制:wallets 表没有到期列,也没有任何清理任务(唯一命中的 expires_at 是 AI 助手订阅期,不是能量)。余额没有上限:裸 BIGINT,无 CHECK。单次的上下限只有三个:打赏 10 – 100000、提现起提 1000、助手三档定价。

除开户 INSERT 外,写 wallets.green 的地方六处,每一处的锁:商城扣款(事务 + FOR UPDATE)、内购加钱(事务,靠 UPDATE ... RETURNING 的原子性,无 FOR UPDATE)、退款扣回(事务 + FOR UPDATE)、签到加钱(事务 + FOR UPDATE)、打赏扣款(事务 + FOR UPDATE)、买助手扣款(事务 + 两把 FOR UPDATE)。

红能量是个空壳:列建了、商品表有 price_red、读取时返回,但全库没有任何一处写它,扣款代码也只读 price_green

出处:[code@6fcfe8e6 backend/internal/domain/wallet/ledger.go:38] [code@6fcfe8e6 backend/internal/domain/wallet/ledger.go:66-82] [code@6fcfe8e6 backend/internal/domain/checkin/repository.go:101-145] [code@6fcfe8e6 backend/internal/domain/shop/repository.go:129-157] [code@6fcfe8e6 backend/internal/domain/iap/repository.go:57] [code@6fcfe8e6 backend/internal/domain/iap/repository.go:96-122] [code@6fcfe8e6 backend/internal/domain/music/handler.go:2050-2058] [code@6fcfe8e6 backend/internal/domain/assistant/assistant.go:535-655] [code@6fcfe8e6 backend/migrations/000130_wallet_shop.up.sql:7-8]。

3.3 分成:一个出处,三个数,零头按路径不同

比例的唯一出处是 revshare,它存在的目的就是消灭原来散在四处的写死数字:

  • GameDevPct = 20 —— 游戏开发者拿两成,内购和广告同一个数(Jeff 2026-08-05 定,原为 5/5)。
  • MusicCreatorPct = 50 —— 音乐打赏里创作者那一侧;至今没人拍板改过
  • CreatorSide(total, pct) 写成 total - total*(100-pct)/100 而不是 total*pct/100:后者在 pct=50、total=11 时把零头给了平台,方向正好反。CreatorSide 的零头归创作者那一侧,因为第二层(music.splitShares)的余数归主创,两层朝相反方向跑没法向人解释。但只有音乐打赏用它:商城是 price*20/100 余数留平台,广告 devPool 分池整除余数也留平台。三条路零头方向不一样,别概括成一句。

别处全是转手,不是第二出处:商城 shop.devSharePct = revshare.GameDevPct、广告 defaultAdDevSharePct = revshare.GameDevPct、音乐 creatorCut()revshare.CreatorSide迁移 000132 的注释仍写着「分成 = 5/5」,那是过期的注释,不是第二个规则。

谁触发结算:商城消费和音乐打赏是买家点下去那一刻同事务入账(苹果充值本身只写用户台账 / 余额 / 流水,不给任何 dev 入账),没有周期;广告是管理员手动录一期总收入才分(POST /v1/dev/admin/ad-revenue,任意 from/to 区间)。全仓没有任何定时任务在分钱。

一个例外要知道:录广告收入那一期时可以传 dev_share_pct 覆盖默认值,所以「人人 20%」是默认、不是代码保证;回包里的 ad_pct_is_default 就是给这件事看的。

出处:[code@6fcfe8e6 backend/internal/domain/revshare/revshare.go:20-49] [code@6fcfe8e6 backend/internal/domain/shop/repository.go:29] [code@6fcfe8e6 backend/internal/domain/shop/repository.go:174-185] [code@6fcfe8e6 backend/internal/domain/music/split.go:38-46] [code@6fcfe8e6 backend/internal/domain/developer/adrevenue.go:126-131] [code@6fcfe8e6 backend/migrations/000132_dev_revenue.up.sql:2] [decision@2026-08-05,Jeff 平台 80 / 开发者 20,条款写明不追溯] [decision@2026-08-26,Leong Sen Fong 「千万不要在不同地方走不同的规则」→ 收成一个出处]。

3.4 广告:上报事实、手动分账、一道默认关着的闸

App 侧 ads.showRewarded 只有一个调用方:H5 游戏的 JS 桥。onUserEarnedRewardearned=true,showRewarded 把 bool 直接回给游戏 —— 发奖是游戏自己做的。后端那条 POST /v1/games/{id}/adunawaited 的事实上报:客户端不传钱不传身份(game 来自路径、uid 来自 token),写一行 ad_events,写失败只记日志不打断玩家。上报里还有一道减害:同一人同游戏「看完」超过每小时上限时,仍落日志但不计入权重

分账在另一条路:管理员录一期总收入 → 按各游戏 completed 加权分池 —— 权重总和包含平台自营游戏,轮到它们那份时跳过不入账、留平台;单人单游戏单日权重封顶 30,整除余数留平台 → 写 ad_revenue_runs((period_from, period_to) 唯一索引只挡起止日期完全相同的重复,重叠区间可以重复覆盖)+ 写 dev_ledger

这条路默认是关着的:AD_REVENUE_ALLOW_WITHOUT_SSV != "1" 直接 412 ssv_required。理由写在代码注释里:「看完」还是客户端自报的,分出去的是别人的钱、分了追不回来。这是 Jeff 2026-08-18「SSV 上线前再说」那句话的机器代记 —— 光写文档挡不住「忘了」。

出处:[code@6fcfe8e6 backend/internal/domain/games/ad.go:186-218] [code@6fcfe8e6 backend/internal/domain/developer/adrevenue.go:94-99] [code@6fcfe8e6 backend/internal/domain/developer/adrevenue.go:148-155] [code@6fcfe8e6 backend/internal/domain/developer/adrevenue.go:204-225] [code@6fcfe8e6 backend/migrations/000167_ad_revenue_runs.up.sql:9-22] [code@6fcfe8e6 app/lib/services/ad_service.dart:148-198] [code@6fcfe8e6 app/lib/screens/game_webview_screen.dart:1093-1115] [decision@2026-08-18,Jeff]。

3.5 提现:代码只到「记一行」,钱由人汇

见 §1.1 第 4-5 步。这里只补三件反直觉的:

  1. 额度在申请那一刻就冻结扣了(dev_ledger 一行负数),所以「已付」不写账本、「拒绝」才补回一行正数。payoutsdev_ledger 之间靠 ref 认亲,没有外键
  2. eKYC 那道闸只管新申请:已经排在 payouts 里的老单子一个字节没动 —— 那是 Leong Sen Fong 08-26 拍板的建议 B,追溯去卡等于把规则改到别人头上。
  3. App 那颗「去验证」的键只是告知,直接打接口绕得过去 —— 真正拦得住的是后端问库那一下。代码注释自己写着这条判据:钱要出去之前,问的是库,不是界面。
  4. 结算那一侧没有防并发:见 §1.1 第 5 步;两人同时处理一单、或同一人点两次,状态会互相覆盖,拒绝的补正流水可能写两次。

收款方式 payouts.method 是自由文本,开发者自己填(银行 / PayPal / TNG…),存的时候把收款人姓名拼进去(姓名 · 方式)。管理员待付列表的 JSON 里那个 usd 字段装的其实是 MYR(旧名兼容,旧包还在读)。

出处:[code@6fcfe8e6 backend/internal/domain/developer/developer.go:2206-2226] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2271-2283] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2358-2371] [code@6fcfe8e6 backend/migrations/000132_dev_revenue.up.sql:17-26] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2307] [decision@2026-08-26,Leong Sen Fong eKYC 闸只管新申请]。

3.6 eKYC:存什么、谁能看、留多久

kyc_submissions 存:姓名、证件类型(mykad / passport)、证件号密文 + 后四位、国家、生日、三张照片的 key、拒绝原因、审核人、审核时间、photos_purged_at;后加 liveness_passed(可空三态)和 nationality。职业 / 行业 / 住址三列被 000362 加过、当天被 000363 整个撤回(Leong Sen Fong 09-08 07:26 「proceed to implement」拿掉三格)。

照片不进库,字节在对象存储(生产是 Cloudflare R2),库里只有 key。谁能看:GET /v1/kyc/photo 本人或 ADMIN_UIDS 名单里的管理员,签 5 分钟短命地址,每次留痕(kyc_photo_views 不挂外键、本人自查也记);解密全号只有 GET /v1/kyc/admin/detail 一处。限流走 Redis,没接或出错一律放行

留多久:通过 7 年、被拒 90 天、长期 pending 90 天(按被拒那档);删号另有 30 天宽限,正常运行、无积压无故障时落在 30 天, 30 天+6 小时。这三个数在 2026-09-07 之前没有人拍过板,是写代码的人自己提的;2026-09-08 16:49 Leong Sen Fong 拍了「keep the default」,但依据是「eKYC 算不算 reporting institution 这个问题还没有律师答案之前偏向多留」,不是确认 7 年是对的

出处:[code@6fcfe8e6 backend/migrations/000298_kyc_submissions.up.sql:24-46] [code@6fcfe8e6 backend/migrations/000357_kyc_liveness.up.sql:14] [code@6fcfe8e6 backend/migrations/000363_kyc_drop_profile_fields.up.sql] [code@6fcfe8e6 backend/internal/domain/kyc/purge.go:94-97] [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:203-241] [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:777-783] [code@6fcfe8e6 backend/migrations/000358_kyc_photo_views.up.sql:32-39] [decision@2026-09-08,Leong Sen Fong keep the default] [decision@2026-09-08,Leong Sen Fong 拿掉职业/行业/住址]。

3.7 权限边界:一份环境变量名单,不是角色表

判据统一是 ADMIN_UIDS 环境变量(逗号分隔的 uid,默认空),developer 和 kyc 两个包各自注入建 map。管理员限定的端点:录广告收入、待付提现列表、标记已付/拒绝、待批开发者与游戏、批开发者、批游戏、KYC 待审列表、KYC 详情(唯一解密处)、KYC 批/拒;GET /v1/kyc/photo 是「本人或管理员」。

三个例外要记住:GET /v1/dev/admin/pending 对非管理员回 200 + is_admin:false,不是 403;POST /v1/iap/apple/notify 完全无鉴权(身份靠 JWS 验签,苹果没法带我们的 token);GET /v1/revshare 故意不鉴权(对外价目表)。

没有任何人工调账端点 —— KindAdmin 这个流水种类常量定义了,但全库没有写入方;opsmcp 包对 wallets / dev_ledger / payouts 零命中。要改一个人的余额,今天只能直接连库。

出处:[code@6fcfe8e6 backend/internal/config/config.go:326] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:64-70] [code@6fcfe8e6 backend/internal/domain/kyc/kyc.go:252-260] [code@6fcfe8e6 backend/internal/domain/developer/developer.go:2856] [code@6fcfe8e6 backend/internal/domain/iap/handler.go:26-27] [code@6fcfe8e6 backend/internal/domain/wallet/ledger.go:26]。

4. 已知缺口与待核事项(逐行看状态)

状态四种,别混着读:已核实没做的 = 在基线代码里找过、确实没有;现状 = 现在就是这么设计的,不一定是病;待核 = 需要连生产库 / 真机 / 看服务器 env 才能定,本文没做;待拍板 = 等人做决定,不是等人写代码。

事项 状态 出处 / 怎么核
一绿的入口定价与出口结算没有统一口径 待确认(经济口径) 条款 deploy/www/developer-terms.html §6 写 USD $0.01;美区参考档 $0.99 = 500 绿 ≈ $0.00198(各地区档位不是统一面值);界面 100 绿 = 1 MYR;提现代码写死 ÷100。历史上有定价 / 显示的决定(§7),入口与出口从没被拍成一个数
提现 / 内购真实收付按哪个币种 待确认(Jeff 2026-08-25 只定了「现在用 myr」是显示口径,收付没碰) developer.go:1954 注释仍写 = $10
音乐打赏 50% 要不要跟着游戏改 待确认(2026-08-05 起挂着,原话「待 Jeff 一个字」) revshare.go:27
eKYC 保留期 现行决定已定(09-08「keep the default」)、法律依据待律师 EKYC_LEGAL_FACT_SHEET_2026-09-08.md 结尾 15 问的第 1 问至今无答案
Google Play 内购 已核实没做的 搜过 google.?play / androidpublisher / purchaseToken / play billing,后端零命中
任何支付通道(提现真出钱) 已核实没做的 搜过 stripe / paypal / payoneer / adyen / razorpay / billplz / toyyibpay / senangpay / duitnow / wise 等,零命中;最后一步就是 UPDATE payouts
人工调账端点 已核实没做的 KindAdmin 常量无写入方;ops / mcp 包对三张钱表零命中
用户看自己的绿能量流水 已核实没做的(后端有端点,App 零调用方) api_client.dart:4220 无调用方
shopWallet()(平台钱包 / 开局 1000 绿) 已核实没做的(App 零调用方) api_client.dart:3917
KYC 预签名后从未提交的孤儿对象 已核实没做的(没有清理路径) purge.go:185-197due() 只扫 kyc_submissions 的行
音乐打赏没有幂等键 已核实没做的 重复 POST 会重复扣款重复入账,防线只有 wallets 行锁
两张账本没有业务幂等唯一约束(都有 id 主键) 现状 幂等靠各自业务表的主键或 wallet_ledger.ref 查询(助手续期),不靠约束
开户白送两条路径,池路径不在事务里 现状 / 缺口:GetWallet / checkin.Status 传连接池,INSERT 1000 与 grant 流水分开提交、丢流水不补;Buy / CheckIn 传 tx 一起回滚;内购开户零余额、不送 shop/repository.go:66-72,117-123checkin/repository.go:74-76,101-108wallet/ledger.go:66-82iap/repository.go:49-51
签到 Commit 后读 history 现状:读失败时签到和 +1 已提交 checkin/repository.go:140-151
提现结算无防并发 现状 / 缺口:SELECT pendingFOR UPDATE,UPDATE 只按 id、无状态条件、不看影响行数 developer.go:2351-2371
内购入账不比对 AppAccountToken 与登录 uid 现状 / 缺口:transaction_id 防重复不证明属于这个 HOOP 账号 iap/handler.go:40-75iap/repository.go:29-40iap/verify.go:53
打赏分成输入在事务外读 现状 / 缺口:合作者查询错回空、锁定状态错回未锁定,仍按回落分 music/collaborators.go:165-180music/split.go:326-334
广告分账区间重叠 现状:唯一索引只挡完全相同区间 000167:22
用户账本只从迁移 000172 起算 现状(不是 bug,是上线时点) 「逐行累加 = 余额」要有可对账的期初余额且后续流水完整;注册时间本身不保证,池路径开户漏 grant 是已核反例
退款扣不满 现状 流水记实际扣回额、与余额一致;:short 只是标记不含差额;差额 = 台账原额 − 实际扣回额
苹果退款通知处理失败仍回 200 现状(避免苹果重发风暴) 只记日志,靠人工查台账补
零头方向按路径 现状 商城、广告余数留平台;音乐打赏 CreatorSide 余数归创作者
广告分账默认关着(AD_REVENUE_ALLOW_WITHOUT_SSV) 现状 / 闸门(SSV 没接) adrevenue.go:94-99
useTestAds = true(跑测试广告位) 现状 / 有意为之 —— Jeff 2026-08-19 要求改回来的,自测期拿真位自己点会被 AdMob 判无效流量;上架前必须置回 false ad_service.dart:30,ad_service_config_test.dart 会在置 false 时逼你先补齐安卓真位
安卓真广告位仍是占位符 …/0000000000 现状 / 缺口 ad_service.dart:39;prodUnitReady() 就是为它写的守卫
充值页冷启动余额永远画 现状 / 缺口 refresh() 不拉余额
内购失败(取消 / store 拒 / 后端 500)界面全程不出声 现状 / 缺口 iap_service.dart:110-171 四处都只 debugPrint
红能量建了列但无任何写入 现状 当它不存在
App 分成比例兜底可能被显示出去 现状 / 缺口 没进过开发者中心的人在音乐工作室看到写死的 50
生产 env 里 IAP_ALLOW_SANDBOX / AD_REVENUE_ALLOW_WITHOUT_SSV / KYC_ENC_KEY / ADMIN_UIDS 的实际值 待核 仓库里没有生产 env;代码默认值见 §6
线上 payouts / ad_revenue_runs / kyc_submissions 现在各多少行 待核 只知道 2026-09-08 那天核过:payouts 0 行、kyc_submissions 1 行 pending、kyc_photo_views 0 行
原生相机落在手机磁盘上的 KYC JPEG 谁清 待核 Dart 侧只读字节从不删;iOS HoopCamera.swift / Android HoopCameraPlugin.kt 没读
助手订阅扣绿不写 dev_ledger 现状(本基线 Activate 扣款链核过:只写 wallets + wallet_ledger,平台自营) assistant/assistant.go:535-655
wallet / shop 包测试覆盖率 0.0% 现状(旧 PRD 09-07 核过的数,今天未重跑)

5. 出问题先查哪里 · 动它之前

用户说「我充值了没到账」:① 问他 App 有没有报错 —— 多半没有,因为四种失败界面全不出声;② 查 iap_transactions 有没有那条 transaction_id:有 = 入账事务提交过,再看 user_id 是不是他、status 是不是 refunded(别拿别的账号或已退款的交易证明他该有余额);没有 = 收据没到后端、验签没过、或入账事务失败;③ 查后端日志验签那几行;④ 交易没 finish 的话,让他杀进程重开 App(启动订阅会重投),或按充值页的「恢复购买」;⑤ 后端 500 / 断网那次不能断言没入账,以台账为准。

用户说「我的绿能量少了 / 多了」:wallet_ledgeruser_id 拉流水,看 kindref。⚠️ 2026-08 之前的变动没有流水,对不上先看用户注册时间;开户那 1000 的 grant 流水也可能没记上(Ensure 不回滚不补),先充值开户的人根本没有那 1000。

开发者说「我的收入不对」:dev_ledger 是唯一真相,余额就是 SUM(green)。分成比例别信界面(App 有兜底值),信 revshare 包那两个常量。游戏收入页只统计 salead 两类。

开发者说「提现按不下去 / 报错」:按九道闸的顺序排:不是 approved 开发者 → 403;不够 1000 绿 → 400;没填收款方式 → 400;kyc_required → 没过 eKYC;payee_mismatch → 收款人姓名和证件姓名对不上(比的是折叠大小写和空白之后);409 → 已经有一笔 pending 还没结。

「提现单子卡在处理中」:可能在等人线下汇款(代码这边不会自己变),也可能是结算失败或没人更新;先看 payouts.settled_at / note,再问管理员。

管理员说「看不到审核台 / 待付列表」:查服务器 ADMIN_UIDS 里有没有他的 uid —— 那是环境变量,不是数据库里的角色。

动它之前: - 改分成比例只改 revshare 包那两个常量,别在任何别处写第二个数(这个包存在的全部理由)。条款写明不追溯已记录的收入。 - wallets.green 的任何新代码,必须在一个事务里同时写 wallet_ledger,并且照抄现有那几处的 FOR UPDATE(Ensure 走连接池的那两条调用就没做到,别照抄它们;传 tx 进去才受保护)。 - 动结算 POST /v1/dev/admin/payout:先补 FOR UPDATEWHERE status='pending' 和影响行数判定,再改别的。 - 今天的幂等在两类地方:业务表约束(内购台账、inventory、daily_checkins、payouts 唯一索引)和助手续期那样查 wallet_ledger.ref;账本的 id 主键不是业务幂等约束。要加业务唯一约束先想清归哪一类,别把现状当铁律。 - 动 KYC 保留期之前先看 EKYC_LEGAL_FACT_SHEET_2026-09-08.md,那三个数是拍过板的(「keep the default」),不是随手写的。 - 常规到期清理不抹 full_name(抹了提现姓名比对就永远过不去);删号 Eraser 必须抹,两条路别混(§1.2)。 - 上架前把 useTestAds 置回 false,并且先把安卓真广告位建出来(有测试在等这一步)。 - 要跑第一次广告分账之前先做 AdMob SSV;实在要跑,是在服务器上显式设 AD_REVENUE_ALLOW_WITHOUT_SSV=1,那等于书面承担「分的是别人的钱」这个风险。

6. 不能碰的数(当前值 / 实现出处 / 守卫或未覆盖)

当前值 出处 守卫
开户白送 1000 绿(仅商城 / 签到首次开户;内购开户 0) wallet/ledger.go:38iap/repository.go:52 ON CONFLICT DO NOTHING 保证不重送;grant 流水在池路径(GetWallet / 签到 Status)不保证,tx 路径一起回滚
充值六档 500 / 1600 / 3400 / 6500 / 14000 / 34000 iap/products.go:21-28 Product ID 在 ASC 建立后永久不可复用,改内容量只能下架旧的建新的
参考价(美区,对账用) 99 / 299 / 599 / 1099 / 2199 / 4999 美分 同上 真正给用户看的价来自 StoreKit
每日签到 +1 绿,一天一次,UTC+8 切日 checkin/repository.go:27 (user_id, day) 主键
打赏单笔 10 – 100000 绿 music/handler.go:2025 接口校验
AI 助手三档 400 / 1200 / 12000 绿 assistant/assistant.go:89-91
游戏开发者分成 20%(内购与广告同一个数) revshare/revshare.go:22 revshare_test.go;条款 §6 对外承诺 20%
音乐创作者分成 50% revshare/revshare.go:27 revshare_test.go 证过与旧的 (green+1)/2 等价
零头方向 音乐打赏归创作者(CreatorSide);商城、广告余数留平台 revshare/revshare.go:44-49shop/repository.go:174-185adrevenue.go:204-225 三处各自
单人单游戏单日广告权重上限 30 developer/adrevenue.go:44 分池 SQL 里生效
最低提现 1000 绿(注释写 = $10,界面写 MYR 10) developer/developer.go:1954 见 §4 待拍板
提现 advisory lock 命名空间 8601(申请侧;结算侧无锁) developer/developer.go:2175 加上 payouts_one_pending_per_dev 唯一索引
绿 → 钱 换算 ÷100(App 侧 100 绿 = 1 MYR) app/lib/util/green_money.dart:43developer.go 两处 App 侧有 green_money_single_source_test.dart 钉着单一出处;后端那两处不在守卫范围内
KYC 保留期 通过 7 年 / 被拒 90 天 / pending 90 天 kyc/purge.go:94-97 拍板见 §3.6
KYC 清理节奏 每 6 小时一轮、每轮 200 行 kyc/purge.go:78-79 删号 30 天宽限在正常运行、无积压无故障时落在 [30, 30+6h],非硬上限
KYC 限流 上传 12/分 · 看图 30/分 · 管理列表 60/分 · 审核 30/分 kyc/kyc.go:203-219 Redis 没接或出错一律放行
预签名有效期 上传 10 分钟 / 看图 5 分钟 kyc/kyc.go:383-385kyc/kyc.go:607
沙盒收据 IAP_ALLOW_SANDBOX == "true" 才接受,默认空 = 不接受 config/config.go:321 生产实际值 待核
广告分账闸 AD_REVENUE_ALLOW_WITHOUT_SSV == "1" 才放行,默认拒 developer/adrevenue.go:94 生产实际值 待核
广告位 当前跑测试位;安卓真位是占位符 app/lib/services/ad_service.dart:30-39 ad_service_config_test.dart

7. 出处与旧记录去向

并入的五份(蓝图 §5.5 点名),按可信度排:

  1. hoopstudio/hoopstudio-wallet-prd.md(v1.1,2026-09-07,净室重跑过每个数)—— 这批里最可靠;它的「两个钱包」框架是本文 §0 和 §3.1 的底子。它自己声明与代码冲突时以代码为准。
  2. GREEN_ENERGY_ASBUILT.md(2026-08-10 as-built)—— 表结构、幂等、退款、六档、三个扣款点都仍成立。⚠️ 它写「汇率 2026-08-09 从 $0.01 重定为 $0.002」的日期存疑:CHANGELOG 里做这件事那一刀在 07-17/18 之间,08-09 是它自己的核查日;引用别照抄这个日期(待核,没查 git log)。
  3. IAP_GREEN_ENERGY.md(设计图纸)—— 四处已知与代码不符,由 as-built 那份逐条列出:enableStoreKit2() 已废弃、迁移 SQL 少 DEFAULT ''、「锁住整页」实为按钮级锁、/v1/wallet/ledger 归包归错(iap 包,不在 wallet)。
  4. CREATOR_REVENUE_UI_AUDIT.md(2026-08-21,基线 c7f53469)—— UI 部分大面积过期:它列的「游戏没有 Revenue 屏」「没有日期筛选」等 ❌ 已被 08-24 到 09-08 十几刀推翻。目录里还把它当现行挂着(见下)。
  5. AD_MONETIZATION.md(2026-07-15)—— 设计方向仍成立(AdMob 起步、SSV 终局);它自己已用红块标注「MVP 客户端发奖」那句不成立了。运营事实变了:真广告位那段没有记 08-19 切回测试位、也没记安卓真位是占位符。

不作废、只取工程事实的两份:EKYC_LEGAL_FACT_SHEET_2026-09-08.md(法务面留在它那儿,15 个给律师的问题第 1 问无答案)和 EKYC_PRIVACY_RETENTION_AUDIT_2026-09-08.md。⚠️ 后者的核心结论「隐私条款里没有 eKYC 这一段」已被前者推翻(英文条款 09-08 15:37 已补,中文版仍没有),而它自己没有标作废 —— 读那份时以前者 §6.1 为准。

不并入:WARMTH_ECONOMY.md —— 温度是 0–1000 的关系分,不是货币;和绿能量双向 grep 零命中,没有任何兑换通道;蓝图把它划给 castle。这里只留这一句指针。

两处目录该跟着改(本文没改,留给下一刀):docs/00-目录.md 里 EKYC 事实清单那一行的基线还是旧的、还写着「到期只清照片、文字资料永不过期」(09-08 已推翻,证件号密文和生日跟照片同一个钟);CREATOR_REVENUE_UI_AUDIT 仍当现行文档挂着。

拍板记录(时间由旧到新,只留还在生效或解释了现状的):

日期 定了什么 后来
2026-07-08 前后 Jeff 分成 5/5、线下汇款、1 绿 = $0.01、最低提现 $10 分成 08-05 被自己推翻;线下汇款至今成立
2026-07-15 Jeff 广告用 AdMob、HoopFintech 独立主体收款 仍成立
2026-07-17/18(日期待核) Jeff 重定美区参考档 $0.99 = 500 绿(≈ $0.00198),不动余额不动商品价 档位表照做;出口那一侧没跟上,成了今天的面值分歧
2026-07-27 Jeff 删号方案 B:30 天宽限,不瞬间物理清除 仍成立
2026-08-05 Jeff 分成定案:平台 80 / 开发者 20,内购与广告同比例;条款写明不追溯 仍成立;打赏 50% 当时留白「待 Jeff 一个字」,至今没等到
2026-08-14 / 08-17 李敏 音乐合作者内部分账:定好不能改、必须合作者也点头 仍成立
2026-08-18 Jeff SSV 上线前再说 → 落成 AD_REVENUE_ALLOW_WITHOUT_SSV 默认拒 闸门仍挂着
2026-08-19 Jeff 「把我们广告设置去测试广告先」 useTestAds 至今 true
2026-08-23 李敏 「please change all $ to MYR」—— 只换符号,/100 没动 制造了币种口径分歧
2026-08-25 Jeff 「现在用 myr」 → 后端 usd 字段改名 myr(待付列表仍同时发 usd 兼容旧包) 显示口径定了,收付币种没碰
2026-08-26 Leong Sen Fong 「千万不要在不同地方走不同的规则」 → 分成收进 revshare 一个出处 生产真数据核过:18 笔打赏各一半;唯一一笔 08-03 的内购按 50% 不追溯
2026-08-26 Leong Sen Fong 「现在有支出功能所以需要这个验证」 → eKYC 上线,提现那道闸接上 仍成立;闸只管新申请
2026-08-27 Leong Sen Fong 每天签到一次 +1 绿,同一天不能重复;签到入口移出通知 仍成立
2026-09-08 李敏 钱包那颗 ⓘ 不要弹小抄,要带我去那一页 仍成立
2026-09-08 Leong Sen Fong eKYC 拿掉职业 / 行业 / 住址三格(000363 撤回 000362);保留期「keep the default」 生产上那三列全 NULL、无人填过

本文没做的:没连生产库、没跑一笔真交易、没装机、没看服务器 env、没跑后端测试。凡是「线上多少行」都标了是哪一天谁核的。