用 CCW 金币换 KukeChat 里的装饰(气泡皮肤、头像框、昵称颜色……)。金币是 CCW 社区签到就能拿到的娱乐货币,没有现金价值。
所有路径前缀 /api/v1,都要求登录。新错误的 detail 是 { code, message }。
KukeChat 自己不发币、不存币。余额是算出来的:
余额 = 这个人给 KukeChat 官方作品累计投出的币 − 在商店里已经花掉的币 − 送礼花掉的币 − 群升级花掉的币
− 发出的红包总额 + 抢到的红包 + 退回的红包 + 后台赠送的币(扣除记为负数)
「后台赠送的币」(balance.granted)是管理员在后台手动加 / 扣的金币(见 5.2),记在 KukeChat 账号上、不跟 CCW 账号走(换绑不影响,也不进下面的换绑防刷规则);和抢到的红包一样能花,但花之前同样要绑定 CCW 账号、能向余额来源核实(购买的流程不变)。群升级见 group-plus.md。
后三项是红包(red-packets.md):发红包时立刻扣整包金额,没被领完的部分在红包结束时退回;抢到的币和投出来的币一样能花。币只在用户之间流动,总量不变。
「送礼花掉的」是礼物(gifts.md):送礼物、送商店商品给别人,是真消耗,对方不得币。
- 「累计投出的币」由服务端通过可插拔的余额来源核实(按用户绑定的 CCW 账号匹配,见 1.1),不采信客户端报的数。按用户投出的数量算,不扣平台回收的那部分。
- 以前给官方作品投过币的人,绑定 CCW 账号后自带余额。
- 必须先绑定 CCW 账号。没绑定的人能逛商店,不能买。没绑定的人也能抢红包,币记在账上,绑定之后才能花。
- 充值就是去官方作品投币:客户端打开
shop.donate_url(作品页),用户投完回来点「刷新余额」。任何平台(扩展、桌面端、移动端)都是这个流程,客户端不调用任何投币接口。
「某个 CCW 账号累计投了多少币」不是这个仓库里的代码去查的。商店只依赖一个接口 —— app/integrations/coin_ledger.py 的 CoinLedger:
| 方法 | 含义 |
|---|---|
configured() |
现在能不能核实余额 |
credited_to(account_id) |
这个账号累计入账多少;确实没有返回 0,核实不了抛 CoinsUnavailable |
recent_credits(limit) / all_credits(fresh=) |
最近入账的账号 / 全部账号的名单(后台对账用全量) |
topup_url() |
充值页地址,就是接口里的 donate_url |
source() |
余额来源的标识和它取自哪个配置项,后台设置页展示用 |
实现由部署方提供(见 README 的「集成点」)。核实不了一律按「现在不知道」处理,绝不当成 0。没有接入余额来源时和「没配置官方作品」一样:能逛、能领等级解锁的商品,donate_url 为 null,已绑定用户的余额恒为 stale,花币购买返回 503 verification_unavailable。
商品目录在服务端,后台可以上架、下架、改价,不需要发版。
GET /shop 的 categories 给出当前有哪些类别:[{ key, label, slot }]。每个类别对应一个装备槽,一个槽同时只能装备一件;不同的槽互不影响,可以同时装备一件气泡和一件头像框。目前有:
| key | slot | 说明 | style |
|---|---|---|---|
bubble |
bubble |
聊天气泡皮肤,只改自己发出的消息的气泡 | 2.2 |
frame |
frame |
头像框:套在自己头像外面的一圈环,别人在消息、成员列表、资料卡上看到的我的头像都带着它 | 2.4 |
客户端遇到不认识的类别就不显示它的商品。
style 是一组受限的字段,客户端按它画气泡;不是任意 CSS。服务端保存时会校验。
{
"version": 1,
"background": "linear-gradient(135deg, #7F7FD5 0%, #86A8E7 50%, #91EAE4 100%)", // 纯色或渐变
"text": "#ffffff", // 文字颜色
"link": "#e8f4ff", // 链接颜色,可空(空则沿用 text)
"border": { "color": "#ffffff33", "width": 1 }, // 可空;width 0–3
"radius": 14, // 4–22,可空(空则用主题默认)
"shadow": "0 2px 8px #7F7FD566", // 可空
"animation": "flow", // none / flow(渐变缓慢流动)/ shimmer(扫光)/ breathe(光晕呼吸),可空
// 叠在底色上的纹样,可空。key 是客户端内置的图案
"pattern": { "key": "stars", "color": "#ffffff", "opacity": 0.18, "scale": 1 },
// 贴在气泡角上的小装饰,可空,最多 2 个。key 是客户端内置的矢量图形
"ornaments": [
{ "key": "sparkle", "corner": "tr", "color": "#fff6c4", "size": 18, "animated": true },
{ "key": "moon", "corner": "bl", "color": "#ffe9a8", "size": 14 }
],
"dark": { "background": "…", "text": "…", "border": { … }, "shadow": "…", "pattern": { … } } // 深色模式下的覆盖,可空
}-
pattern.key∈dots圆点 /grid细格 /stripes斜纹 /waves波浪 /stars星点 /hearts小爱心 /pixels像素块 /clouds云朵 /petals花瓣 /noise细颗粒。opacity0–0.6,scale0.5–2。 -
ornaments[].key∈sparkle闪光 /star星星 /moon月亮 /sun太阳 /heart爱心 /petal花瓣 /cloud云 /paw猫爪 /leaf叶子 /crown皇冠 /pixel_heart像素爱心 /bolt闪电 /snow雪花。corner∈tl/tr/bl/br(装饰骑在气泡的角上,略微探出去一点);size10–28;animated为 true 时轻微地闪 / 晃(同样受「减少动态效果」约束)。 -
dark里只能出现background/text/link/border/shadow/pattern,没写的沿用浅色的值;圆角、动效、角饰两种配色共用。 -
图案和装饰都是客户端内置的,样式里只给名字和颜色,不能带图片地址。客户端遇到不认识的
key就跳过那一项,其余照常画——所以以后加新图案要发版,但用已有图案组合出新皮肤不用。 -
颜色只接受
#rgb/#rrggbb/#rrggbbaa;background只接受一个颜色或一个linear-gradient(...)/radial-gradient(...)(里面只能有角度、颜色和百分比);shadow只接受「偏移 模糊 [扩散] 颜色」的形式,最多两层。 -
客户端必须尊重「减少动态效果」的系统设置(此时
animation当作none)。 -
不认识的
version或字段:忽略这件皮肤,按没装备处理。
UserPublic 新增 cosmetics,按槽给出装备着的商品,没装备的槽不出现,一件都没装备时是 {}:
{
"bubble": { "sku": "bubble.aurora", "style": { /* 2.2 */ } },
"frame": { "sku": "frame.galaxy", "style": { /* 2.4 */ } }
}消息的发送者对象里带着它,客户端据此画别人的气泡和头像框。旧客户端不认识这个字段、或者不认识其中某个槽,不受影响(不认识的槽直接忽略)。
装备变化时发实时事件 user.cosmetics.updated:{ "user_id": 42, "cosmetics": { … } },发给所有在线用户(同一个人 5 秒内最多一次,取最后的状态)。cosmetics 是这个人全部槽的最新状态,不是只有变了的那个槽——客户端收到后整个替换本地缓存里这个用户的 cosmetics,已经显示出来的消息气泡和头像框跟着变。
和气泡一样,style 是一组受限的字段,客户端按它在头像外面画一圈环;不是任意 CSS,服务端保存时会校验。所有尺寸都按 40px 的头像计,客户端按头像实际大小等比缩放。
{
"version": 1,
"ring": {
"background": "conic-gradient(from 0deg, #7F7FD5, #91EAE4, #7F7FD5)", // 纯色,或 linear / radial / conic 渐变
"width": 3, // 2–6
"gap": 1 // 环和头像之间的留白 0–3,可空(默认 0)
},
"glow": { "color": "#7F7FD588", "blur": 8 }, // 外发光,可空;blur 0–16
"animation": "spin", // none / spin(环的渐变缓慢旋转)/ pulse(发光呼吸)/ shimmer(环上扫光),可空
// 贴在环上的装饰,可空,最多 3 个。key 是客户端内置的矢量图形
"ornaments": [
{ "key": "crown", "position": "top", "color": "#ffd76a", "size": 18, "animated": false }
],
"dark": { "ring": { "background": "…" }, "glow": { … } } // 深色模式下的覆盖,可空
}ring必填,其中background和width必填。glow写了的话color和blur都要有。ring.background接受一个颜色,或者一个linear-gradient(...)/radial-gradient(...)/conic-gradient(...)。前两种的规则和气泡的background完全一样;conic-gradient(...)里只能有开头可选的from <角度>deg(-360 到 360)、颜色和百分比,2–8 个颜色——不能写at <位置>、不能用角度当色标、不能用turn/rad。只有这里放开了 conic,气泡的background仍然不接受。要让spin转起来没有接缝,首尾写同一个颜色。ornaments[].key∈ 气泡的全部角饰(见 2.2),再加头像框专用的:cat_ears猫耳 /halo光环 /horns小恶魔角 /laurel月桂 /wings翅膀 /bow蝴蝶结。这几个专用的不能用在气泡上。ornaments[].position∈top/tr/right/br/bottom/bl/left/tl(环上的八个方位),同一个位置只能放一个;size10–28;animated同气泡。注意位置字段叫position,不是气泡的corner。dark里只能出现ring.background和glow(glow要写就写完整的color+blur),没写的沿用浅色的值;环的粗细、留白、动效、装饰两种配色共用。装饰没有深色覆盖,颜色要选在浅色和深色背景上都看得见的。- 颜色格式、「减少动态效果」(此时
animation当作none、animated当作 false)、不认识的version/ 字段 /key的处理,都和气泡一致(见 2.2)。 - 服务端首次启动时播种 11 件默认头像框(只在 sku 不存在时插入,后台改过的不动):
frame.mint薄荷 10 /frame.sakura樱花 30 /frame.ocean海盐 60 /frame.neko猫耳 60 /frame.galaxy星环 100 /frame.aurora极光 200 /frame.halo光环(4 级解锁)/frame.crown加冕(16 级解锁)/frame.bow蝴蝶结 30 /frame.devil小恶魔 50 币 / 30 天 /frame.angel天使 120 币 / 30 天(这两件是限时的,见 2.5;其余永久)。它们的样式和客户端dev/mocks/shop.ts的FRAMES是同一套,要调先在客户端看效果,再两边一起改。 - 后台编辑器需要的几张表在
GET /admin/shop/settings里:frame_animations、frame_ornaments、frame_positions。
商品的 duration_days 为 null 是永久(一直以来的行为);为 N 是「买一次拥有 N 天」。
- 购买(
POST /shop/items/{id}/purchase,等级够了免费领也一样):没拥有(或者已经到期)→ 到期时间 = 现在 + N 天;已拥有、还没到期 → 在原到期时间上再加 N 天(续期),每续一次扣一次钱。永久拥有着的商品重复购买仍是 409already_owned。 - 我拥有一件限时商品、还没到期、而且它现在还能买(上架中,卖或者等级够了能领)时,
obtainable是renew;不能买了就是owned。商品对象的expires_at是我这一件的到期时间。 - 到期:视同不再拥有——
owned为 false、cosmetics里消失、装备接口返回 403not_owned。这些都是读的时候现场判断的,不依赖定时清扫:到期的那一刻起,任何接口、任何消息里的用户对象上都不再有它。装备着的限时商品的到期时间和样式一起记在用户行上,所以序列化用户仍然不查别的表。 - 定时清扫(每个进程每
SHOP_EXPIRY_SWEEP_INTERVAL_SECONDS秒,默认 60)只做读路径做不了的两件事:把到期的那一件从装备里真的卸下并照常发user.cosmetics.updated,以及通知本人(见第 4 节的shop.item.expired/shop.item.expiring)。多个进程同时清扫是幂等的,每件事每次到期只发生一次。所以事件最多比真正的到期时间晚一分钟——客户端收到用户对象时cosmetics已经是对的,自己的商店页按expires_at判断即可。 - 到期提醒在到期前 3 天发一次。续期之后到期时间往后推了,快到新的到期时间时会再提醒一次。一得到就不足 3 天的(比如后台送了 1 天的体验)不提醒。
- 到期之后可以再买,从那时起重新算 N 天。
- 等级解锁、后台赠送也按商品的
duration_days计期;后台赠送可以另传duration_days覆盖(见第 5 节)。等级解锁的限时商品到期之后不会自动再发,等级够的人自己去商店再领一次(obtainable = level,免费)。 - 别人送的商品(
gifts.md)走同一套规则:对方没有就给 N 天,已有限时的就续期,已经永久拥有则送不出去。 - 修改商品的
duration_days只影响之后的购买 / 赠送,不动已有的拥有记录。把限时商品改成永久之后,已经拥有限时版的人再买一次会换成永久的。 - 购买记录(3.5)每续一次各有一条;被后台收回时收回的是当前的那一条——选了退币也只退最后一次的钱。
{
"categories": [{ "key": "bubble", "label": "气泡", "slot": "bubble" }, { "key": "frame", "label": "头像框", "slot": "frame" }],
"items": [ /* 上架中的商品,以及我已拥有的(哪怕已下架) */ ],
"balance": {
"bound": true, // 是否已绑定 CCW 账号
"donated": 320, // 服务端核实到的累计投币
"spent": 260,
"balance": 60,
"verified_at": "…", // 上次向 CCW 核实的时间;从没核实过为 null
"stale": false, // true 表示上次核实失败,余额可能不是最新的
"red_packet_sent": 0, // 红包:发出的整包金额之和 / 抢到的 / 没被领完退回来的
"red_packet_received": 0,
"red_packet_refunded": 0,
"gift_spent": 0, // 送礼物 / 送商品花掉的币
"group_plus_spent": 0, // 买群升级花掉的币
"granted": 0 // 后台赠送的币(赠送 − 扣除,见 5.2)。
// balance = donated − spent − gift_spent − group_plus_spent − red_packet_sent + red_packet_received + red_packet_refunded + granted
},
"equipped": { "bubble": "bubble.aurora", "frame": "frame.galaxy" }, // 每个槽装备的 sku,没装备的槽不出现
"donate_url": "https://www.ccw.site/detail/…",
"level": 23 // 我的账号等级(用来判断等级解锁)
}向 CCW 重新核实一次累计投币,返回 balance 对象。每人 20 秒内最多一次(429 refresh_too_fast,detail.retry_after 秒)。没绑定 CCW:409 ccw_not_bound。CCW 那边查不到或出错:返回上次的数并且 stale: true,不报错。
没有请求体。服务端会先重新核实一次余额再扣。成功返回:
{ "item": { /* 商品对象,owned: true */ }, "balance": { /* … */ }, "equipped": { /* 没变 */ } }等级够了可以免费领的商品也走这个接口,不扣币。限时商品的购买和续期也是这个接口(2.5):返回的商品对象里 expires_at 是新的到期时间。
| HTTP | detail.code |
含义 |
|---|---|---|
| 404 | item_not_found |
商品不存在 |
| 409 | item_unavailable |
已下架或已过期 |
| 409 | already_owned |
已经永久拥有了(拥有的是限时商品时不是错误,是续期) |
| 409 | ccw_not_bound |
没绑定 CCW 账号 |
| 409 | item_locked |
不卖,且等级不够 |
| 402 | insufficient_coins |
余额不够(余额按第 1 节的完整公式算:送礼花掉的、发出去的红包已经扣掉,抢到的算在里面)。detail 里带 balance(数字,当前余额)、price、shortfall、donate_url |
| 503 | verification_unavailable |
现在核实不了余额(余额来源不通,或者没有接入),稍后再试。不会扣币 |
body { "slot": "bubble", "sku": "bubble.aurora" },sku 传 null 表示卸下。→ { "equipped": { … } }(所有槽的状态)。只能装备自己拥有的(403 not_owned)。头像框是 { "slot": "frame", "sku": "frame.galaxy" };一次只动一个槽,别的槽不变。
我的购买记录:{ items: [{ id, item: { sku, name, category }, price, source, created_at, expires_at, duration_days, from_user }], total }。
source∈purchase买的 /level等级解锁 /grant后台赠送 /gift别的用户送的(gifts.md;price为 0,from_user是送礼人的UserPublic,其余来源from_user为 null)。duration_days是这一次得到了多少天,永久的为 null。expires_at是这一条记录到什么时候为止:当前的那一条就是这件商品的到期时间;续期之前的那几条到续期的那一刻为止(它剩下的时间已经并进了接替它的那一条);永久的为 null。
GET /shop不会向 CCW 发请求,只返回上次核实的结果。绑定后从没核实过的人verified_at是 null、余额是 0——客户端打开商店时如果bound为 true 且verified_at为 null,应当自动调一次POST /shop/balance/refresh。- 没配置官方作品(或者没有接入余额来源,见 1.1)时
donate_url为 null,余额恒为stale,购买返回 503。 - 购买时的检查顺序:
item_not_found→already_owned→item_unavailable→ 等级免费领 /item_locked→ccw_not_bound→ 503 → 402。等级够了的免费领取不要求绑定 CCW。 - 同一个 CCW 账号的币不能换绑到别的 KukeChat 账号再花一遍:
balance.spent里包含这个 CCW 账号在别的 KukeChat 账号上已经花掉的部分,以及在别的账号上用红包发出去(没退回)的部分、送礼花掉的部分。 GET /shop/purchases的每条多revoked_at(被后台收回的时间,否则 null)和refunded。被收回的商品可以再次获得。- 被后台收回时,本人只会在「正装备着这件」的情况下收到
user.cosmetics.updated;商店页的「已拥有」要到下次拉取才变。 user.cosmetics.updated第一次变化立即发,5 秒内的后续变化合并成最后一次。- 其它错误码:422
invalid_slot(装备时槽不存在,或者这件商品不是这个槽的——比如把头像框往bubble槽里装)。
| 事件 | data | 说明 |
|---|---|---|
shop.changed |
{} |
商品目录有变化(上架、下架、改价),发给所有在线用户,同一进程 30 秒内最多一次。收到后在商店页打开时重拉 |
shop.item.granted |
{ item: { sku, name, category }, source } |
我得到了一件商品(后台赠送,或升级后自动解锁)。别的用户送我商品时不发这一条,只发 gift.received(gifts.md) |
shop.item.expiring |
{ item: { sku, name, category }, expires_at } |
我的一件限时商品快到期了(到期前 3 天,每次到期只发一次),只发给本人。见 2.5 |
shop.item.expired |
{ item: { sku, name, category } } |
我的一件限时商品到期了,装备着的话已经被卸下,只发给本人(每次到期只发一次)。最多比到期时间晚一分钟 |
user.cosmetics.updated |
{ user_id, cosmetics } |
有人换了装扮,见 2.3 |
shop.balance.changed |
{ reason, change, balance, red_packet_id } |
我的余额变了,只发给本人(别的设备跟着变)。balance 是完整的 balance 对象(3.1),可以直接整个替换本地的,也可以收到就重拉。reason 是来源:"red_packet" 或 "gift";change 是具体哪一种:red_packet.sent 发了红包 / red_packet.claimed 领到了 / red_packet.refunded 红包结束、没领完的退回来了(带 red_packet_id)/ gift.sent 送了礼(带 gift_id,送礼记录的 id)/ coin_grant.granted、coin_grant.deducted 后台给我加 / 扣了金币(reason = "coin_grant",带 grant_id、amount,见 5.2)。商店购买、后台退款暂时不发(购买接口的返回里已经带了最新的 balance)。客户端遇到不认识的 reason / change 也照样更新余额,以后别的来源加进来不用改 |
| 接口 | 说明 |
|---|---|
GET /admin/shop/items |
全部商品(含下架的),每项带已售数量 |
POST /admin/shop/items、PUT /admin/shop/items/{id} |
新建 / 修改:sku(新建后不可改)、category、name、description、price、unlock_level、style、preview_text、tag、is_active、sort、available_until、duration_days(有效期,1–3650 天;null 是永久。不合法是 422 invalid_item,detail.field = "duration_days"。改它只影响之后的购买 / 赠送)。style 按 category 校验(气泡 2.2,头像框 2.4),不合法返回 422 invalid_style 并说明哪个字段;只改 category 不改 style 时,旧样式也得符合新类别的规则 |
GET /admin/shop/purchases?user_id=&item_id=&limit&offset |
购买记录。每项带 expires_at、duration_days(含义同 3.5)、superseded_at(被续期接替的时间,当前的那一条为 null)、from_user(source = gift 时是谁送的:{ id, username, nickname, avatar_url })。按 user_id 筛出来的就是这个人的拥有列表:没被收回、expires_at 为 null 或者还没到的那几条是现在拥有的 |
GET /admin/shop/accounts?q=&mismatch=&limit&offset |
每个用户的累计投币、已花、送礼花掉的(gift_spent)、群升级花掉的(group_plus_spent)、后台赠送的净额(granted)、红包收支(red_packet_sent / red_packet_received / red_packet_refunded)、余额、上次核实时间;mismatch=true 只看余额为负的(每项的 mismatch 也是这个口径)。抢到过红包的人也在这个列表里 |
POST /admin/shop/reconcile |
向余额来源拉一次完整的投币名单,更新所有已绑定用户的累计投币,返回 { donors, matched, updated, mismatched: [...], red_packets: {...}, gifts: {...} }(gifts 是送礼的账的守恒检查,见 gifts.md 5.1)。拉不到完整名单时 503,什么都不改。有了红包之后「花的比投的多」不再必然是异常(抢到的币也能花),所以查的是:mismatched 余额为负的账户,和 red_packets 全站红包账的守恒({ total_sent, total_claimed, total_refunded, outstanding, balanced, problems },见 red-packets.md 5.1) |
GET /admin/shop/grants?user_id=&admin_id=&direction=&limit&offset |
后台赠送 / 扣除金币的记录,新的在前。见 5.2 |
POST /admin/shop/grants |
{ user_id, amount, reason } 给用户加(正数)/ 扣(负数)金币。见 5.2 |
POST /admin/shop/users/{user_id}/grant |
{ item_id, note, duration_days } 赠送。duration_days 不传 = 按商品的设置;正整数(≤ 3650)= 这一份只给这么多天;0 = 送成永久。给已经拥有这件限时商品的人再送 = 续期(在原到期时间上加);已经永久拥有是 409 already_owned。范围不对是 422 invalid_duration |
POST /admin/shop/users/{user_id}/revoke |
{ item_id, note, refund } 收回(同时卸下)。收回的是当前的那一条记录;refund 为 true 时只退那一条的钱 |
GET /admin/shop/settings |
官方作品、各项阈值,以及样式里能用的名字:categories、tags、气泡的 animations / patterns / ornaments、头像框的 frame_animations / frame_ornaments / frame_positions,还有 price_max、unlock_level_max、duration_max(只读) |
审计日志:赠送仍是 shop.grant(details 多了 duration_days:这一份给了多少天,永久为 null),改商品仍是 shop.item.update。限时商品到期是系统行为,不写审计日志。
赠送 / 扣除金币是 shop.coins.grant(target_type = "user",details:{ grant_id, amount, reason, balance_before, balance_after })。
shop_purchases每一行多了expires_at、duration_days、superseded_at、from_user_id、gift_record_id和两个「已通知」时间。「当前的那一行」= 没被收回、没被接替;唯一索引(uq_shop_purchases_live)只管它,所以一个人一件商品同时只有一行当前记录。「现在拥有」= 当前的那一行没到期。- 续期是把当前的那一行标成被接替、再写一行新的(带累加之后的到期时间),所以每次花的钱、得到的天数各有一行,换绑防刷的口径(按记录上的 CCW 账号求和)不用变。
- 并发续期不丢天数:接替是一条带条件的 UPDATE(那一行必须还是当前记录),抢不到的事务整个回滚、按对方提交之后的到期时间重来;两个事务同时写第一行,后提交的撞唯一索引,同样重来。花币的路径上扣款那条 UPDATE 在前,事务从那里起就拿着数据库的写锁,后一个事务读到的一定是前一个提交之后的到期时间。
表 coin_grants(只增不改):id, user_id, amount, reason, admin_id, created_at。amount 正数是赠送、负数是扣除;余额公式直接对它求和(balance.granted),没有另外的余额列。
POST /admin/shop/grants
// 请求
{ "user_id": 12, "amount": 500, "reason": "活动奖励" }
// 200
{ "grant": { "id": 3, "user_id": 12, "user": { "id": 12, "username": "…", "nickname": "…", "avatar_url": null },
"amount": 500, "reason": "活动奖励", "admin_id": 1, "admin": { /* 同 user */ }, "created_at": "…" },
"balance_before": 60,
"balance": { /* 这个用户写入之后的完整 balance 对象(3.1),含 granted */ } }| HTTP | detail.code |
含义 |
|---|---|---|
| 422 | invalid_amount |
amount 不是整数、是 0,或者绝对值超过 1,000,000(detail.field = "amount") |
| 422 | invalid_reason |
reason 为空或超过 200 字(detail.field = "reason")。首尾空白会去掉 |
| 404 | user_not_found |
用户不存在 |
| 409 | insufficient_balance |
扣除之后余额(按第 1 节完整公式算)会变成负数。带 balance(现在的余额)、amount。什么都不写 |
- 没有账户的人(没投过币、没核实过)赠送时会建一行账户,之后出现在
GET /admin/shop/accounts里。 - 写入和余额检查在同一个写事务里(先过
_write_lock,事务第一条语句就拿写锁),扣除不会和同时进行的花币交错成负数。 - 成功后给本人发
shop.balance.changed(reason = "coin_grant"),并在通知中心出现一条system类别的通知(type = "coin_grant",标题「后台赠送了你 N 金币」/「后台扣除了你 N 金币」,正文是reason;保留 30 天)——所以reason要写成可以给用户看的话。
GET /admin/shop/grants?user_id=&admin_id=&direction=grant|deduct&limit=50&offset=0 → { items: [同上 grant], total, summary: { granted, deducted, net } }(summary 是筛选范围内的合计)。
服务器上也可以用命令行做,见 group-plus.md 9.3 的 --grant-coins。
{ "id": 12, "sku": "bubble.aurora", // 稳定的标识 "category": "bubble", // 见 2.1 "name": "极光", "description": "会缓缓流动的渐变气泡", "price": 200, // 金币;0 表示不卖,只能靠解锁或赠送获得 "unlock_level": null, // 账号等级达到多少免费解锁;null 表示不能靠等级解锁 "style": { /* 按类别不同:气泡见 2.2,头像框见 2.4 */ }, "preview_text": "今天也要开心呀", // 预览用的示例文字,可空 "tag": "new", // new / hot / limited / null "available_until": null, // 限时销售:到这个时间自动停售(和下面的 duration_days 不是一回事) "duration_days": null, // 买一次拥有多少天;null 是永久。见 2.5 "is_active": true, // false 表示已下架(只会出现在我已拥有的商品上) "owned": false, "equipped": false, "obtainable": "buy", // 我现在能怎么得到它:buy 可以买 / level 等级够了可以免费领 / locked 等级不够且不卖 / owned 已拥有 / renew 我拥有的是限时的,可以再买来续期 "expires_at": null // 我拥有的这一件什么时候到期;永久或者没拥有为 null }