From 66178edc7c7562add16073b646431fe23e8ff44b Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 12 Jul 2026 19:35:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B3=A5=E7=82=B9=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E4=B8=8E=E6=AF=8F=E6=97=A5=E5=85=8D=E8=B4=B9=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增每日免费泥点独立余额桶,按北京时间零点固定重置20泥点 - 按每日免费、会员限时、永久顺序扣费并支持跨日退款按原分桶回补 - 统一主站与图片画板泥点入口,隐藏限时泥点、任务入口和会员购买入口 - 收敛四档充值商品与泥点购买弹窗,并接入后台动态商品配置 - 同步余额read model、SpacetimeDB迁移绑定、回归测试和工程文档 - 将泥点明细浮层圆角匹配首页卡片视觉规范 --- .../src/pages/AdminRechargeProductPage.tsx | 6 +- .../shared-memory/decision-log.md | 16 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 49 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 25 +- packages/shared/src/contracts/runtime.ts | 23 + .../crates/api-server/src/runtime_profile.rs | 172 +++- .../crates/module-runtime/src/application.rs | 20 + server-rs/crates/module-runtime/src/domain.rs | 30 + server-rs/crates/module-runtime/src/lib.rs | 99 +-- .../crates/shared-contracts/src/runtime.rs | 83 ++ .../spacetime-client/src/mapper/puzzle.rs | 6 + .../src/mapper/runtime_profile.rs | 22 +- .../spacetime-client/src/module_bindings.rs | 30 + .../profile_daily_free_points_table.rs | 162 ++++ .../profile_daily_free_points_type.rs | 61 ++ ...profile_daily_free_points_snapshot_type.rs | 19 + ...runtime_profile_dashboard_snapshot_type.rs | 3 + ...e_profile_recharge_center_snapshot_type.rs | 2 + ..._profile_wallet_ledger_source_type_type.rs | 4 + .../crates/spacetime-module/src/migration.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 827 +++++++++++++++++- .../PlatformMudPointWalletEntry.test.tsx | 82 ++ .../common/PlatformMudPointWalletEntry.tsx | 255 ++++++ .../common/platformMudPointWalletModel.ts | 10 + .../ImageCanvasEditorShellView.test.tsx | 9 +- .../ImageCanvasEditorView.test.tsx | 81 +- .../image-editor/ImageCanvasEditorView.tsx | 57 +- .../ImageCanvasTopbarView.test.tsx | 77 +- .../image-editor/ImageCanvasTopbarView.tsx | 54 +- .../PlatformProfileRechargeModal.test.tsx | 161 ++-- .../PlatformProfileRechargeModal.tsx | 130 ++- .../usePlatformProfileCenterController.ts | 30 +- .../RpgEntryHomeView.recharge.test.tsx | 509 +++++------ src/components/rpg-entry/RpgEntryHomeView.tsx | 252 +----- .../rpgEntryProfileFundsViewModel.test.ts | 34 +- .../rpgEntryProfileFundsViewModel.ts | 2 + src/index.css | 24 +- src/services/rpg-entry/rpgProfileClient.ts | 4 +- 40 files changed, 2506 insertions(+), 929 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs create mode 100644 src/components/common/PlatformMudPointWalletEntry.test.tsx create mode 100644 src/components/common/PlatformMudPointWalletEntry.tsx create mode 100644 src/components/common/platformMudPointWalletModel.ts diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index ef5745ade..2e18e3c6c 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -49,10 +49,10 @@ export function AdminRechargeProductPage({ const [priceCents, setPriceCents] = useState('600'); const [kind, setKind] = useState('points'); const [pointsAmount, setPointsAmount] = useState('60'); - const [bonusPoints, setBonusPoints] = useState('60'); + const [bonusPoints, setBonusPoints] = useState('0'); const [durationDays, setDurationDays] = useState('0'); - const [badgeLabel, setBadgeLabel] = useState('首充双倍'); - const [description, setDescription] = useState('首充送60泥点'); + const [badgeLabel, setBadgeLabel] = useState(''); + const [description, setDescription] = useState('60泥点'); const [tier, setTier] = useState('normal'); const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0'); const [membershipPeriodDays, setMembershipPeriodDays] = useState('0'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0604cd677..5e2820f50 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-07-12 泥点充值收敛为四档并统一资产入口 + +- 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。 +- 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 +- 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 +- 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-07-12 每日免费泥点独立于任务并按北京时间日切 + +- 背景:现有“每日免费泥点”实际只是每日登录任务领取奖励,领取后进入普通永久余额,既不是独立余额桶,也不会在次日失效;同时主站仍展示每日任务卡片和任务中心入口,与新的产品口径不一致。 +- 决策:新增 `profile_daily_free_points` 作为每日免费泥点事实源,基础额度固定为 `20`,以北京时间 `day_key` 为业务日;跨日后的首次余额读取或扣费原子清除昨日剩余及退款叠加量,并把当日额度重置为 `20`,对外语义始终视为北京时间 `00:00` 已重置。扣费按“每日免费 -> 会员周期限时 -> 永久”顺序;资产退款在同一业务日恢复原每日免费额度,跨业务日时把原每日免费消费部分叠加到退款当日每日免费桶,当日允许超过 `20`,下一业务日仍统一重置为 `20`。每日任务系统和 `daily_task_reward` 保留为普通永久奖励,但主站隐藏每日任务卡片及任务中心入口。 +- 影响范围:`profile_daily_free_points`、`profile_wallet_ledger`、个人资金 read model、钱包扣费和退款 metadata、主站“我的”页、SpacetimeDB 迁移与生成绑定。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、钱包定向 Rust 测试、个人中心定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-07-11 SpacetimeDB 工具链统一升级到 2.6.0 - 背景:生产数据副本验证已使用 2.6.0 standalone,而仓库 Rust crate、本地 CLI、生成 bindings、容器与 server provision 仍锁定 2.5.0 或更早版本,继续混用会增加 BSATN / procedure 返回值与发布产物错配风险。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9e3c44482..ebff57a16 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,7 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 -- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip,点击后复用创作主页右上角泥点入口的账户充值 / 兑换码弹窗与支付确认反馈。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧复用与主站相同的公共泥点资产入口,并以充值中心 `mudPointBalance` 为余额真相源。余额区只展开不限时、每日免费及重置信息,会员周期限时泥点保留在后端 read model 中用于存量兼容和结算但不展示;独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 762f78875..28f6af8ea 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -189,18 +189,20 @@ npm run check:server-rs-ddd ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 -2. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。 -3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。 -4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。 -5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。 -6. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。 -7. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。 -8. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant`、`membership_period_reset`。 -9. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。 -10. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。 -11. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 -12. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 -13. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。 +2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 + 90 泥点 / 1800 分`、`points_300 = 300 + 150 泥点 / 3000 分`、`points_680 = 680 + 340 泥点 / 6800 分`。`points_60` 的首充赠送为 `0`;后三档首次购买分别赠送基础泥点的 `50%`。 +3. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。 +4. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。充值中心 BFF 还必须在 `mudPointBalance` 下发 `totalPoints`、`permanentPoints`、`limitedPoints`、`limitedExpiresAt`、`dailyFreePoints`、`dailyFreeResetPoints` 和 `dailyFreeResetsAt`;前端以该 read model 为真相源,不得自行用总额相减推算余额桶。当前版本公开 UI 只渲染不限时泥点和每日免费泥点,`limitedPoints` 与 `limitedExpiresAt` 仅保留给存量兼容和后端结算。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。 +5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。 +6. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。 +7. 当前版本公开充值 UI 只展示泥点商品,不渲染会员购买页签、会员商品、购买会员或升级会员入口。充值中心响应中的会员商品兼容字段、默认会员商品、`profile_membership` 和周期刷新逻辑继续保留;存量会员的 `cycle_remaining_points` 仍通过充值中心 read model 下发用于兼容和结算,但不作为限时泥点在当前版本前台展示。 +8. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。 +9. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。 +10. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant`、`membership_period_reset`。 +11. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5`、`wechat_native`,生产配置不得把真实支付静默降级为 `mock`。 +12. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。 +13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 +14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 +15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。 ## 创作入口泥点扣费契约 @@ -215,13 +217,14 @@ npm run check:server-rs-ddd ## 用户钱包与编辑器生成扣费契约 1. 新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `profile_wallet_config.initial_mud_points`;后台通过 `/admin/api/profile/wallet-config` 维护“账号初始泥点数”。未写入配置时默认仍为 `100` 泥点。流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。 -2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费时优先消耗 `profile_membership.cycle_remaining_points` 中的会员周期限时泥点,再消耗普通永久泥点;扣费流水 `metadata_json` 会记录 `membershipPeriodPointsDelta`、`permanentPointsDelta` 和限时泥点所属 `cycleResetsAtMicros`,退款会按原消费流水优先恢复同一周期的限时泥点,前端不得自行决定扣费桶。 -3. 每日免费泥点当前由每日任务体系发放,流水来源为 `daily_task_reward`,任务进度和可领取状态按北京时间每日刷新;它不参与会员 `cycle_resets_at`,也不由前端合并进会员周期泥点。 -4. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。 -5. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 -6. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 -7. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -8. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,只允许提交已登记的 generated `objectKey`、`resourceId` 或 `assetId`;任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费必须按“每日免费泥点 -> 会员周期限时泥点 -> 普通永久泥点”的顺序消耗,前端不得自行决定扣费桶。扣费流水 `metadata_json` 必须记录 `dailyFreePointsDelta`、`dailyFreeDayKey`、`membershipPeriodPointsDelta`、`permanentPointsDelta` 和会员限时泥点所属 `cycleResetsAtMicros`;资产退款中的会员限时泥点只在原周期仍有效时恢复会员额度,其余会员部分进入普通永久泥点。原每日免费消费部分在同一业务日退款时恢复原当日额度;跨北京时间业务日退款时叠加到退款当日每日免费桶,不进入普通永久泥点,当日 `granted_points` 与 `remaining_points` 均可因此超过 `20`。原永久泥点消费部分无论是否跨业务日,均按退款流水中的 `permanentPointsDelta` 退回普通永久泥点。 +3. 每日免费泥点是独立于每日任务和会员周期的正式余额额度,基础发放量固定为 `20`,不得由前端或后台任务配置改写。`profile_daily_free_points` 保存当前北京时间业务日、当日基础发放及跨日退款叠加后的总额度和剩余额度;北京时间每日 `00:00` 作为业务日边界,个人中心、充值中心、账单读取和钱包扣费入口在首次触达新业务日时原子清除昨日剩余及退款叠加量,并把今日 `granted_points`、`remaining_points` 重置为 `20`。首次初始化使用 `daily_free_grant` 流水,跨日重置使用 `daily_free_reset` 流水。惰性落库不能改变“北京时间 00:00 后读取即为新日额度”的对外语义。 +4. 每日任务奖励继续使用 `daily_task_reward` 流水并进入普通永久泥点,但主站隐藏每日任务卡片和任务中心入口,不再把每日登录任务描述为“每日免费泥点”。任务配置、进度、领取记录和后台管理能力暂时保留,除非后续需求明确删除。 +5. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。 +6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 +7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。 +8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 +9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,只允许提交已登记的 generated `objectKey`、`resourceId` 或 `assetId`;任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。objectKey 必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,后端通过归属校验后才签名读取 OSS。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 ## 外部服务与资产 @@ -691,6 +694,12 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileDashboardState` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +### `profile_daily_free_points` + +- Rust 结构体:`ProfileDailyFreePoints` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量固定为 `20`,`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过 `20`,下一业务日首次触达时旧余额与叠加量一并失效并重置为 `20`。 + ### `profile_feedback_submission` - Rust 结构体:`ProfileFeedbackSubmission` @@ -717,7 +726,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileRechargeProductConfig` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护。 +- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。 - 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。 ### `profile_played_world` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 9badb0239..3e19ec162 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,7 +12,7 @@ 旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。 -创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。 +创作页和草稿页顶栏右上角统一复用公共泥点资产入口,不再把余额区本身作为直接充值按钮。余额区展开后只展示不限时泥点、每日免费泥点及重置口径;会员周期限时泥点仅由后端保留用于存量兼容和结算,当前版本不在前台展示。独立“充值”按钮进入“购买更多泥点”弹窗,“使用详情”进入泥点账单。主站各位置必须保持同一组件、数据口径和交互语义,头像 / 设置等账号入口继续保留各自语义。 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 8f6ad684c..8665e27cf 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -56,14 +56,15 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 ## 账户与充值 -1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。 -2. 泥点默认档位为 `60 / 180 / 300 / 680 / 1280 / 3280`,会员默认档位为月卡、季卡、年卡;实际展示、下单校验和支付确认都以后端返回的充值商品配置为准。 -3. 首充双倍按泥点商品档位独立计算。用户买过 `points_60` 后,只影响 `points_60` 的首充展示和结算,其它未购买档位仍保留各自首充权益。 -4. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 -5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 -6. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。 -7. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。 -8. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。 +1. 主站和图片画板统一使用公共泥点资产入口。收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及“每天重置为 20 泥点”,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。 +2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。 +3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。 +4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。 +5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。 +6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。 +7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。 +8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。 +9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。 ## 唯一后端路线 @@ -102,10 +103,10 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 -11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数和进度;外层任务卡不展示“去完成”等左右侧行动按钮,领取 / 去完成 / 已完成状态只在任务中心弹窗内表达。任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。用户停留在“我的”页跨过北京时间 0 点时,前端必须非阻断刷新登录态以补齐 `daily_login` 埋点,再重拉任务中心,避免继续展示上一自然日已领取状态。 -12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 -13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 +11. 每日免费泥点由后端独立余额桶承载,基础额度固定为 `20`,按北京时间每日 `00:00` 重置。跨业务日退款时,原消费中的每日免费泥点部分叠加到退款当日每日免费桶,当日余额允许超过 `20`;到下一业务日仍统一失效并重置为 `20`。主站不得把已隐藏的每日任务入口或 `daily_task_reward` 文案继续当作每日免费泥点入口。 +12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 +13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 7ce7b2439..a01f70547 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -51,6 +51,15 @@ export type ProfileDashboardSummary = { totalPlayTimeMs: number; playedWorldCount: number; updatedAt: string | null; + dailyFreePoints?: ProfileDailyFreePoints; +}; + +export type ProfileDailyFreePoints = { + dayKey: number; + grantedPoints: number; + remainingPoints: number; + resetsAt: string; + updatedAt: string; }; export type ProfileWalletLedgerEntry = { @@ -65,6 +74,8 @@ export type ProfileWalletLedgerEntry = { | 'points_recharge' | 'membership_period_grant' | 'membership_period_reset' + | 'daily_free_grant' + | 'daily_free_reset' | 'asset_operation_consume' | 'asset_operation_refund' | 'redeem_code_reward' @@ -162,6 +173,16 @@ export type ProfileMembership = { cyclePeriodDays: number; }; +export type ProfileMudPointBalance = { + totalPoints: number; + permanentPoints: number; + limitedPoints: number; + limitedExpiresAt: string | null; + dailyFreePoints: number; + dailyFreeResetPoints: number; + dailyFreeResetsAt: string; +}; + export type ProfileRechargeOrder = { orderId: string; productId: string; @@ -183,12 +204,14 @@ export type ProfileRechargeOrder = { export type ProfileRechargeCenterResponse = { walletBalance: number; + mudPointBalance?: ProfileMudPointBalance; membership: ProfileMembership; pointProducts: ProfileRechargeProduct[]; membershipProducts: ProfileRechargeProduct[]; benefits: ProfileMembershipBenefit[]; latestOrder: ProfileRechargeOrder | null; hasPointsRecharged: boolean; + dailyFreePoints?: ProfileDailyFreePoints; }; export type WechatMiniProgramPayParams = { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 0593e9507..10601bf1d 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -9,22 +9,23 @@ use axum::{ }; use hmac::{Hmac, Mac}; use module_runtime::{ - AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, + AnalyticsGranularity, PROFILE_DAILY_FREE_POINTS_PER_DAY, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, - RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, - RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileMembershipBenefitRecord, RuntimeProfileMembershipTier, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, - RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, - RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, - RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, - RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, - RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileFeedbackEvidenceRecord, + RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, + RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, + RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, + RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeTrackingScopeKind, }; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; use serde::{Deserialize, Serialize}; @@ -47,6 +48,8 @@ use shared_contracts::runtime::{ PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, @@ -57,9 +60,10 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileCodeOperationAdminResponse, - ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, - ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, - ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, + ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, + ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, + ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, @@ -119,6 +123,7 @@ pub async fn get_profile_dashboard( total_play_time_ms: record.total_play_time_ms, played_world_count: record.played_world_count, updated_at: record.updated_at, + daily_free_points: build_profile_daily_free_points_response(record.daily_free_points), }, )) } @@ -191,6 +196,12 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::DailyTaskReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD } + RuntimeProfileWalletLedgerSourceType::DailyFreeGrant => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT + } + RuntimeProfileWalletLedgerSourceType::DailyFreeReset => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET + } } } @@ -1635,8 +1646,10 @@ fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 { fn build_profile_recharge_center_response( record: RuntimeProfileRechargeCenterRecord, ) -> ProfileRechargeCenterResponse { + let mud_point_balance = build_profile_mud_point_balance_response(&record); ProfileRechargeCenterResponse { wallet_balance: record.wallet_balance, + mud_point_balance, membership: ProfileMembershipResponse { status: record.membership.status.as_str().to_string(), tier: record.membership.tier.as_str().to_string(), @@ -1668,6 +1681,49 @@ fn build_profile_recharge_center_response( .latest_order .map(build_profile_recharge_order_response), has_points_recharged: record.has_points_recharged, + daily_free_points: build_profile_daily_free_points_response(record.daily_free_points), + } +} + +fn build_profile_mud_point_balance_response( + record: &RuntimeProfileRechargeCenterRecord, +) -> ProfileMudPointBalanceResponse { + let daily_free_points = record.daily_free_points.remaining_points; + let limited_points = record.membership.cycle_remaining_points; + let permanent_points = record + .wallet_balance + .saturating_sub(daily_free_points) + .saturating_sub(limited_points); + let limited_expires_at = (limited_points > 0) + .then(|| { + record + .membership + .cycle_resets_at + .clone() + .or_else(|| record.membership.expires_at.clone()) + }) + .flatten(); + + ProfileMudPointBalanceResponse { + total_points: record.wallet_balance, + permanent_points, + limited_points, + limited_expires_at, + daily_free_points, + daily_free_reset_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + daily_free_resets_at: record.daily_free_points.resets_at.clone(), + } +} + +fn build_profile_daily_free_points_response( + record: RuntimeProfileDailyFreePointsRecord, +) -> ProfileDailyFreePointsResponse { + ProfileDailyFreePointsResponse { + day_key: record.day_key, + granted_points: record.granted_points, + remaining_points: record.remaining_points, + resets_at: record.resets_at, + updated_at: record.updated_at, } } @@ -2152,15 +2208,16 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus, - RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, - RuntimeProfileWalletLedgerSourceType, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipRecord, + RuntimeProfileMembershipStatus, RuntimeProfileMembershipTier, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, + RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, }; use super::{ - build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key, + build_profile_mud_point_balance_response, build_wechat_virtual_pay_params, + calc_wechat_virtual_payment_pay_signature_with_key, calc_wechat_virtual_payment_user_signature_with_key, format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, @@ -2182,6 +2239,63 @@ mod tests { use crate::{app::build_router, config::AppConfig, state::AppState}; + fn daily_free_points_record() -> RuntimeProfileDailyFreePointsRecord { + RuntimeProfileDailyFreePointsRecord { + day_key: 20_646, + granted_points: 20, + remaining_points: 20, + resets_at: "2026-07-12T16:00:00Z".to_string(), + resets_at_micros: 1_783_872_000_000_000, + updated_at: "2026-07-12T08:00:00Z".to_string(), + updated_at_micros: 1_783_843_200_000_000, + } + } + + #[test] + fn mud_point_balance_is_split_by_backend_wallet_buckets() { + let record = RuntimeProfileRechargeCenterRecord { + user_id: "user-1".to_string(), + wallet_balance: 200, + membership: RuntimeProfileMembershipRecord { + user_id: "user-1".to_string(), + status: RuntimeProfileMembershipStatus::Active, + tier: RuntimeProfileMembershipTier::Starter, + started_at: Some("2026-07-01T00:00:00Z".to_string()), + started_at_micros: Some(1_783_036_800_000_000), + expires_at: Some("2026-08-01T00:00:00Z".to_string()), + expires_at_micros: Some(1_785_542_400_000_000), + updated_at: Some("2026-07-12T08:00:00Z".to_string()), + updated_at_micros: Some(1_783_843_200_000_000), + cycle_started_at: Some("2026-07-08T00:00:00Z".to_string()), + cycle_started_at_micros: Some(1_783_468_800_000_000), + cycle_resets_at: Some("2026-07-15T00:00:00Z".to_string()), + cycle_resets_at_micros: Some(1_784_073_600_000_000), + cycle_granted_points: 80, + cycle_remaining_points: 80, + cycle_period_days: 7, + }, + point_products: vec![], + membership_products: vec![], + benefits: vec![], + latest_order: None, + has_points_recharged: false, + daily_free_points: daily_free_points_record(), + }; + + let balance = build_profile_mud_point_balance_response(&record); + + assert_eq!(balance.total_points, 200); + assert_eq!(balance.permanent_points, 100); + assert_eq!(balance.limited_points, 80); + assert_eq!(balance.daily_free_points, 20); + assert_eq!(balance.daily_free_reset_points, 20); + assert_eq!( + balance.limited_expires_at.as_deref(), + Some("2026-07-15T00:00:00Z"), + ); + assert_eq!(balance.daily_free_resets_at, "2026-07-12T16:00:00Z"); + } + #[test] fn profile_wallet_ledger_source_type_formats_backend_values() { assert_eq!( @@ -2214,6 +2328,18 @@ mod tests { ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::DailyFreeGrant + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT + ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::DailyFreeReset + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET + ); } #[tokio::test] @@ -2983,6 +3109,7 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: daily_free_points_record(), }; let params = @@ -3098,6 +3225,7 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: true, + daily_free_points: daily_free_points_record(), }; let params = @@ -3211,6 +3339,7 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: daily_free_points_record(), }; let params = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-user-item01") @@ -3389,6 +3518,7 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: daily_free_points_record(), }; let error = build_wechat_virtual_pay_params(&state, ¢er, &order, "openid-sandbox-1") diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index cb8bf1f6f..a123970e4 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -981,6 +981,23 @@ pub fn build_runtime_profile_dashboard_record( played_world_count: snapshot.played_world_count, updated_at: snapshot.updated_at_micros.map(format_utc_micros), updated_at_micros: snapshot.updated_at_micros, + daily_free_points: build_runtime_profile_daily_free_points_record( + snapshot.daily_free_points, + ), + } +} + +pub fn build_runtime_profile_daily_free_points_record( + snapshot: RuntimeProfileDailyFreePointsSnapshot, +) -> RuntimeProfileDailyFreePointsRecord { + RuntimeProfileDailyFreePointsRecord { + day_key: snapshot.day_key, + granted_points: snapshot.granted_points, + remaining_points: snapshot.remaining_points, + resets_at: format_utc_micros(snapshot.resets_at_micros), + resets_at_micros: snapshot.resets_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, } } @@ -1047,6 +1064,9 @@ pub fn build_runtime_profile_recharge_center_record( .latest_order .map(build_runtime_profile_recharge_order_record), has_points_recharged: snapshot.has_points_recharged, + daily_free_points: build_runtime_profile_daily_free_points_record( + snapshot.daily_free_points, + ), } } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 602ce1f7b..215b80ea1 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -31,6 +31,7 @@ pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login"; pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录"; pub const PROFILE_TASK_DEFAULT_REWARD_POINTS: u64 = 10; pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1; +pub const PROFILE_DAILY_FREE_POINTS_PER_DAY: u64 = 20; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; @@ -674,6 +675,17 @@ pub struct RuntimeProfileDashboardSnapshot { pub total_play_time_ms: u64, pub played_world_count: u32, pub updated_at_micros: Option, + pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileDailyFreePointsSnapshot { + pub day_key: i64, + pub granted_points: u64, + pub remaining_points: u64, + pub resets_at_micros: i64, + pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1077,6 +1089,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyTaskReward, MembershipPeriodGrant, MembershipPeriodReset, + DailyFreeGrant, + DailyFreeReset, } impl RuntimeProfileWalletLedgerSourceType { @@ -1094,6 +1108,8 @@ impl RuntimeProfileWalletLedgerSourceType { Self::RedeemCodeReward => "redeem_code_reward", Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim", Self::DailyTaskReward => "daily_task_reward", + Self::DailyFreeGrant => "daily_free_grant", + Self::DailyFreeReset => "daily_free_reset", } } } @@ -1307,6 +1323,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, + pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1821,6 +1838,18 @@ pub struct RuntimeProfileDashboardRecord { pub played_world_count: u32, pub updated_at: Option, pub updated_at_micros: Option, + pub daily_free_points: RuntimeProfileDailyFreePointsRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileDailyFreePointsRecord { + pub day_key: i64, + pub granted_points: u64, + pub remaining_points: u64, + pub resets_at: String, + pub resets_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq)] @@ -1986,6 +2015,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, + pub daily_free_points: RuntimeProfileDailyFreePointsRecord, } #[derive(Clone, Debug, PartialEq)] diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3d43315dc..409bf2356 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -22,59 +22,33 @@ pub fn format_utc_micros(micros: i64) -> String { pub fn runtime_profile_recharge_point_products() -> Vec { vec![ - build_points_recharge_product( - "points_60", - "60泥点", - 600, - 60, - 60, - "首充双倍", - "首充送60泥点", - ), + build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"), build_points_recharge_product( "points_180", "180泥点", 1800, 180, - 180, - "首充双倍", - "首充送180泥点", + 90, + "首充加赠", + "首充加赠90泥点", ), build_points_recharge_product( "points_300", "300泥点", 3000, 300, - 300, - "首充双倍", - "首充送300泥点", + 150, + "首充加赠", + "首充加赠150泥点", ), build_points_recharge_product( "points_680", "680泥点", 6800, 680, - 680, - "首充双倍", - "首充送680泥点", - ), - build_points_recharge_product( - "points_1280", - "1280泥点", - 12800, - 1280, - 1280, - "首充双倍", - "首充送1280泥点", - ), - build_points_recharge_product( - "points_3280", - "3280泥点", - 32800, - 3280, - 3280, - "首充双倍", - "首充送3280泥点", + 340, + "首充加赠", + "首充加赠340泥点", ), ] } @@ -878,6 +852,13 @@ mod tests { total_play_time_ms: 12, played_world_count: 2, updated_at_micros: Some(1_713_680_000_000_000), + daily_free_points: RuntimeProfileDailyFreePointsSnapshot { + day_key: 19_834, + granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + remaining_points: 12, + resets_at_micros: 1_713_715_200_000_000, + updated_at_micros: 1_713_680_000_000_000, + }, }); assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string())); @@ -917,6 +898,14 @@ mod tests { RuntimeProfileWalletLedgerSourceType::DailyTaskReward.as_str(), "daily_task_reward" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::DailyFreeGrant.as_str(), + "daily_free_grant" + ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::DailyFreeReset.as_str(), + "daily_free_reset" + ); } #[test] @@ -1105,16 +1094,20 @@ mod tests { let point_products = runtime_profile_recharge_point_products(); let membership_products = runtime_profile_recharge_membership_products(); - assert_eq!(point_products.len(), 6); + assert_eq!(point_products.len(), 4); assert_eq!(point_products[0].product_id, "points_60"); assert_eq!(point_products[0].title, "60泥点"); assert_eq!(point_products[0].price_cents, 600); - assert_eq!(point_products[0].bonus_points, 60); - assert_eq!(point_products[0].description, "首充送60泥点"); - assert_eq!(point_products[5].product_id, "points_3280"); - assert_eq!(point_products[5].price_cents, 32800); - assert_eq!(point_products[5].bonus_points, 3280); - assert_eq!(point_products[5].description, "首充送3280泥点"); + assert_eq!(point_products[0].bonus_points, 0); + assert_eq!(point_products[0].badge_label, ""); + assert_eq!(point_products[0].description, "60泥点"); + assert_eq!(point_products[1].product_id, "points_180"); + assert_eq!(point_products[1].bonus_points, 90); + assert_eq!(point_products[2].bonus_points, 150); + assert_eq!(point_products[3].product_id, "points_680"); + assert_eq!(point_products[3].price_cents, 6800); + assert_eq!(point_products[3].bonus_points, 340); + assert_eq!(point_products[3].description, "首充加赠340泥点"); assert_eq!(membership_products.len(), 4); assert_eq!(membership_products[0].product_id, "member_starter"); assert_eq!(membership_products[0].title, "Starter"); @@ -1139,17 +1132,19 @@ mod tests { #[test] fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() { let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); - assert_eq!(first_recharge_products[0].bonus_points, 60); - assert_eq!(first_recharge_products[0].badge_label, "首充双倍"); - assert_eq!(first_recharge_products[0].description, "首充送60泥点"); + assert_eq!(first_recharge_products[0].bonus_points, 0); + assert_eq!(first_recharge_products[0].badge_label, ""); + assert_eq!(first_recharge_products[0].description, "60泥点"); + assert_eq!(first_recharge_products[1].bonus_points, 90); + assert_eq!(first_recharge_products[1].badge_label, "首充加赠"); + assert_eq!(first_recharge_products[1].description, "首充加赠90泥点"); let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); - assert_eq!(repeated_recharge_products[0].bonus_points, 60); - assert_eq!(repeated_recharge_products[0].badge_label, "首充双倍"); - assert_eq!(repeated_recharge_products[0].description, "首充送60泥点"); - assert_eq!(repeated_recharge_products[5].bonus_points, 3280); - assert_eq!(repeated_recharge_products[5].badge_label, "首充双倍"); - assert_eq!(repeated_recharge_products[5].description, "首充送3280泥点"); + assert_eq!(repeated_recharge_products[0].bonus_points, 0); + assert_eq!(repeated_recharge_products[1].bonus_points, 90); + assert_eq!(repeated_recharge_products[3].bonus_points, 340); + assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠"); + assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点"); } #[test] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 2949d5d69..98674d92b 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -11,6 +11,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT: &str = "membership_period_grant"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET: &str = "membership_period_reset"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT: &str = "daily_free_grant"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET: &str = "daily_free_reset"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = @@ -173,6 +175,29 @@ pub struct ProfileDashboardSummaryResponse { pub total_play_time_ms: u64, pub played_world_count: u32, pub updated_at: Option, + pub daily_free_points: ProfileDailyFreePointsResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileDailyFreePointsResponse { + pub day_key: i64, + pub granted_points: u64, + pub remaining_points: u64, + pub resets_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileMudPointBalanceResponse { + pub total_points: u64, + pub permanent_points: u64, + pub limited_points: u64, + pub limited_expires_at: Option, + pub daily_free_points: u64, + pub daily_free_reset_points: u64, + pub daily_free_resets_at: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -305,12 +330,14 @@ pub struct WechatNativePaymentResponse { #[serde(rename_all = "camelCase")] pub struct ProfileRechargeCenterResponse { pub wallet_balance: u64, + pub mud_point_balance: ProfileMudPointBalanceResponse, pub membership: ProfileMembershipResponse, pub point_products: Vec, pub membership_products: Vec, pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, + pub daily_free_points: ProfileDailyFreePointsResponse, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1192,6 +1219,18 @@ mod tests { use super::*; use serde_json::json; + fn empty_mud_point_balance() -> ProfileMudPointBalanceResponse { + ProfileMudPointBalanceResponse { + total_points: 0, + permanent_points: 0, + limited_points: 0, + limited_expires_at: None, + daily_free_points: 0, + daily_free_reset_points: 20, + daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + } + } + #[test] fn runtime_settings_request_uses_camel_case_fields() { let payload = serde_json::to_value(PutRuntimeSettingsRequest { @@ -1260,6 +1299,13 @@ mod tests { total_play_time_ms: 16, played_world_count: 3, updated_at: Some("2026-04-22T10:00:00Z".to_string()), + daily_free_points: ProfileDailyFreePointsResponse { + day_key: 20_646, + granted_points: 20, + remaining_points: 12, + resets_at: "2026-07-12T16:00:00Z".to_string(), + updated_at: "2026-07-12T08:00:00Z".to_string(), + }, }) .expect("payload should serialize"); @@ -1267,6 +1313,7 @@ mod tests { assert_eq!(payload["totalPlayTimeMs"], json!(16)); assert_eq!(payload["playedWorldCount"], json!(3)); assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z")); + assert_eq!(payload["dailyFreePoints"]["remainingPoints"], json!(12)); } #[test] @@ -1444,6 +1491,15 @@ mod tests { fn profile_recharge_center_response_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileRechargeCenterResponse { wallet_balance: 29, + mud_point_balance: ProfileMudPointBalanceResponse { + total_points: 29, + permanent_points: 4, + limited_points: 5, + limited_expires_at: Some("2026-05-25T10:00:00Z".to_string()), + daily_free_points: 20, + daily_free_reset_points: 20, + daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + }, membership: ProfileMembershipResponse { status: "active".to_string(), tier: "month".to_string(), @@ -1476,10 +1532,20 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: ProfileDailyFreePointsResponse { + day_key: 20_646, + granted_points: 20, + remaining_points: 20, + resets_at: "2026-07-12T16:00:00Z".to_string(), + updated_at: "2026-07-12T08:00:00Z".to_string(), + }, }) .expect("payload should serialize"); assert_eq!(payload["walletBalance"], json!(29)); + assert_eq!(payload["mudPointBalance"]["permanentPoints"], json!(4)); + assert_eq!(payload["mudPointBalance"]["limitedPoints"], json!(5)); + assert_eq!(payload["mudPointBalance"]["dailyFreePoints"], json!(20)); assert_eq!( payload["membership"]["expiresAt"], json!("2026-05-25T10:00:00Z") @@ -1493,6 +1559,7 @@ mod tests { json!("首充送60泥点") ); assert_eq!(payload["hasPointsRecharged"], json!(false)); + assert_eq!(payload["dailyFreePoints"]["grantedPoints"], json!(20)); } #[test] @@ -1528,6 +1595,7 @@ mod tests { }; let center = ProfileRechargeCenterResponse { wallet_balance: 0, + mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), tier: "normal".to_string(), @@ -1545,6 +1613,13 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: ProfileDailyFreePointsResponse { + day_key: 20_646, + granted_points: 20, + remaining_points: 20, + resets_at: "2026-07-12T16:00:00Z".to_string(), + updated_at: "2026-07-12T08:00:00Z".to_string(), + }, }; let payload = serde_json::to_value(CreateProfileRechargeOrderResponse { order, @@ -1596,6 +1671,7 @@ mod tests { }; let center = ProfileRechargeCenterResponse { wallet_balance: 0, + mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { status: "normal".to_string(), tier: "normal".to_string(), @@ -1613,6 +1689,13 @@ mod tests { benefits: vec![], latest_order: None, has_points_recharged: false, + daily_free_points: ProfileDailyFreePointsResponse { + day_key: 20_646, + granted_points: 20, + remaining_points: 20, + resets_at: "2026-07-12T16:00:00Z".to_string(), + updated_at: "2026-07-12T08:00:00Z".to_string(), + }, }; let payload = serde_json::to_value(CreateProfileRechargeOrderResponse { order, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 376861b01..47bdfc651 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -609,6 +609,12 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyTaskReward => { module_runtime::RuntimeProfileWalletLedgerSourceType::DailyTaskReward } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyFreeGrant => { + module_runtime::RuntimeProfileWalletLedgerSourceType::DailyFreeGrant + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyFreeReset => { + module_runtime::RuntimeProfileWalletLedgerSourceType::DailyFreeReset + } } } diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 323f67ad7..6fce9412c 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -928,6 +928,21 @@ pub(crate) fn map_runtime_profile_dashboard_snapshot( total_play_time_ms: snapshot.total_play_time_ms, played_world_count: snapshot.played_world_count, updated_at_micros: snapshot.updated_at_micros, + daily_free_points: map_runtime_profile_daily_free_points_snapshot( + snapshot.daily_free_points, + ), + } +} + +pub(crate) fn map_runtime_profile_daily_free_points_snapshot( + snapshot: RuntimeProfileDailyFreePointsSnapshot, +) -> module_runtime::RuntimeProfileDailyFreePointsSnapshot { + module_runtime::RuntimeProfileDailyFreePointsSnapshot { + day_key: snapshot.day_key, + granted_points: snapshot.granted_points, + remaining_points: snapshot.remaining_points, + resets_at_micros: snapshot.resets_at_micros, + updated_at_micros: snapshot.updated_at_micros, } } @@ -995,6 +1010,9 @@ pub(crate) fn map_runtime_profile_recharge_center_snapshot( .latest_order .map(map_runtime_profile_recharge_order_snapshot), has_points_recharged: snapshot.has_points_recharged, + daily_free_points: map_runtime_profile_daily_free_points_snapshot( + snapshot.daily_free_points, + ), } } @@ -1117,9 +1135,7 @@ pub(crate) fn map_runtime_profile_recharge_order_table_row( amount_cents: row.amount_cents, status: map_runtime_profile_recharge_order_status_back(row.status), payment_channel: row.payment_channel, - paid_at_micros: row - .paid_at - .map(|value| value.to_micros_since_unix_epoch()), + paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), provider_transaction_id: row.provider_transaction_id, created_at_micros: row.created_at.to_micros_since_unix_epoch(), points_delta: row.points_delta, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index d76eee791..c0db8c832 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -737,6 +737,8 @@ pub mod player_progression_table; pub mod player_progression_type; pub mod profile_code_operation_table; pub mod profile_code_operation_type; +pub mod profile_daily_free_points_table; +pub mod profile_daily_free_points_type; pub mod profile_dashboard_state_table; pub mod profile_dashboard_state_type; pub mod profile_feedback_submission_table; @@ -1011,6 +1013,7 @@ pub mod runtime_item_reward_item_rarity_type; pub mod runtime_item_reward_item_snapshot_type; pub mod runtime_platform_theme_type; pub mod runtime_profile_code_operation_snapshot_type; +pub mod runtime_profile_daily_free_points_snapshot_type; pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_snapshot_type; @@ -2072,6 +2075,8 @@ pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; pub use profile_code_operation_table::*; pub use profile_code_operation_type::ProfileCodeOperation; +pub use profile_daily_free_points_table::*; +pub use profile_daily_free_points_type::ProfileDailyFreePoints; pub use profile_dashboard_state_table::*; pub use profile_dashboard_state_type::ProfileDashboardState; pub use profile_feedback_submission_table::*; @@ -2346,6 +2351,7 @@ pub use runtime_item_reward_item_rarity_type::RuntimeItemRewardItemRarity; pub use runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; pub use runtime_platform_theme_type::RuntimePlatformTheme; pub use runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +pub use runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; @@ -3031,6 +3037,7 @@ pub struct DbUpdate { npc_state: __sdk::TableUpdate, player_progression: __sdk::TableUpdate, profile_code_operation: __sdk::TableUpdate, + profile_daily_free_points: __sdk::TableUpdate, profile_dashboard_state: __sdk::TableUpdate, profile_feedback_submission: __sdk::TableUpdate, profile_invite_code: __sdk::TableUpdate, @@ -3341,6 +3348,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_code_operation" => db_update.profile_code_operation.append( profile_code_operation_table::parse_table_update(table_update)?, ), + "profile_daily_free_points" => db_update.profile_daily_free_points.append( + profile_daily_free_points_table::parse_table_update(table_update)?, + ), "profile_dashboard_state" => db_update.profile_dashboard_state.append( profile_dashboard_state_table::parse_table_update(table_update)?, ), @@ -3926,6 +3936,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_code_operation, ) .with_updates_by_pk(|row| &row.operation_id); + diff.profile_daily_free_points = cache + .apply_diff_to_table::( + "profile_daily_free_points", + &self.profile_daily_free_points, + ) + .with_updates_by_pk(|row| &row.user_id); diff.profile_dashboard_state = cache .apply_diff_to_table::( "profile_dashboard_state", @@ -4504,6 +4520,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_daily_free_points" => db_update + .profile_daily_free_points + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_dashboard_state" => db_update .profile_dashboard_state .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4931,6 +4950,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_daily_free_points" => db_update + .profile_daily_free_points + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_dashboard_state" => db_update .profile_dashboard_state .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5221,6 +5243,7 @@ pub struct AppliedDiff<'r> { npc_state: __sdk::TableAppliedDiff<'r, NpcState>, player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>, profile_code_operation: __sdk::TableAppliedDiff<'r, ProfileCodeOperation>, + profile_daily_free_points: __sdk::TableAppliedDiff<'r, ProfileDailyFreePoints>, profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, profile_feedback_submission: __sdk::TableAppliedDiff<'r, ProfileFeedbackSubmission>, profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>, @@ -5647,6 +5670,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_code_operation, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_daily_free_points", + &self.profile_daily_free_points, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_dashboard_state", &self.profile_dashboard_state, @@ -6705,6 +6733,7 @@ impl __sdk::SpacetimeModule for RemoteModule { npc_state_table::register_table(client_cache); player_progression_table::register_table(client_cache); profile_code_operation_table::register_table(client_cache); + profile_daily_free_points_table::register_table(client_cache); profile_dashboard_state_table::register_table(client_cache); profile_feedback_submission_table::register_table(client_cache); profile_invite_code_table::register_table(client_cache); @@ -6845,6 +6874,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "npc_state", "player_progression", "profile_code_operation", + "profile_daily_free_points", "profile_dashboard_state", "profile_feedback_submission", "profile_invite_code", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_table.rs new file mode 100644 index 000000000..dea2cd453 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_daily_free_points_type::ProfileDailyFreePoints; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_daily_free_points`. +/// +/// Obtain a handle from the [`ProfileDailyFreePointsTableAccess::profile_daily_free_points`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_daily_free_points()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_daily_free_points().on_insert(...)`. +pub struct ProfileDailyFreePointsTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_daily_free_points`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileDailyFreePointsTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileDailyFreePointsTableHandle`], which mediates access to the table `profile_daily_free_points`. + fn profile_daily_free_points(&self) -> ProfileDailyFreePointsTableHandle<'_>; +} + +impl ProfileDailyFreePointsTableAccess for super::RemoteTables { + fn profile_daily_free_points(&self) -> ProfileDailyFreePointsTableHandle<'_> { + ProfileDailyFreePointsTableHandle { + imp: self + .imp + .get_table::("profile_daily_free_points"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileDailyFreePointsInsertCallbackId(__sdk::CallbackId); +pub struct ProfileDailyFreePointsDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileDailyFreePointsTableHandle<'ctx> { + type Row = ProfileDailyFreePoints; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileDailyFreePointsInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileDailyFreePointsInsertCallbackId { + ProfileDailyFreePointsInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileDailyFreePointsInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileDailyFreePointsDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileDailyFreePointsDeleteCallbackId { + ProfileDailyFreePointsDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileDailyFreePointsDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileDailyFreePointsUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileDailyFreePointsTableHandle<'ctx> { + type UpdateCallbackId = ProfileDailyFreePointsUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileDailyFreePointsUpdateCallbackId { + ProfileDailyFreePointsUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileDailyFreePointsUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `user_id` unique index on the table `profile_daily_free_points`, +/// which allows point queries on the field of the same name +/// via the [`ProfileDailyFreePointsUserIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_daily_free_points().user_id().find(...)`. +pub struct ProfileDailyFreePointsUserIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileDailyFreePointsTableHandle<'ctx> { + /// Get a handle on the `user_id` unique index on the table `profile_daily_free_points`. + pub fn user_id(&self) -> ProfileDailyFreePointsUserIdUnique<'ctx> { + ProfileDailyFreePointsUserIdUnique { + imp: self.imp.get_unique_constraint::("user_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileDailyFreePointsUserIdUnique<'ctx> { + /// Find the subscribed row whose `user_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("profile_daily_free_points"); + _table.add_unique_constraint::("user_id", |row| &row.user_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileDailyFreePoints`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_daily_free_pointsQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileDailyFreePoints`. + fn profile_daily_free_points(&self) -> __sdk::__query_builder::Table; +} + +impl profile_daily_free_pointsQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_daily_free_points(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_daily_free_points") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_type.rs new file mode 100644 index 000000000..5089ac146 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_daily_free_points_type.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileDailyFreePoints { + pub user_id: String, + pub day_key: i64, + pub granted_points: u64, + pub remaining_points: u64, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileDailyFreePoints { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileDailyFreePoints`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileDailyFreePointsCols { + pub user_id: __sdk::__query_builder::Col, + pub day_key: __sdk::__query_builder::Col, + pub granted_points: __sdk::__query_builder::Col, + pub remaining_points: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileDailyFreePoints { + type Cols = ProfileDailyFreePointsCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileDailyFreePointsCols { + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + day_key: __sdk::__query_builder::Col::new(table_name, "day_key"), + granted_points: __sdk::__query_builder::Col::new(table_name, "granted_points"), + remaining_points: __sdk::__query_builder::Col::new(table_name, "remaining_points"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileDailyFreePoints`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileDailyFreePointsIxCols { + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileDailyFreePoints { + type IxCols = ProfileDailyFreePointsIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileDailyFreePointsIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileDailyFreePoints {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs new file mode 100644 index 000000000..7a4178733 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileDailyFreePointsSnapshot { + pub day_key: i64, + pub granted_points: u64, + pub remaining_points: u64, + pub resets_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileDailyFreePointsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs index a3e97b05f..07ffbe95e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_dashboard_snapshot_type.rs @@ -4,6 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeProfileDashboardSnapshot { @@ -12,6 +14,7 @@ pub struct RuntimeProfileDashboardSnapshot { pub total_play_time_ms: u64, pub played_world_count: u32, pub updated_at_micros: Option, + pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, } impl __sdk::InModule for RuntimeProfileDashboardSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs index a9f7f4adb..747739cc1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; use super::runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; use super::runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; @@ -20,6 +21,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub benefits: Vec, pub latest_order: Option, pub has_points_recharged: bool, + pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, } impl __sdk::InModule for RuntimeProfileRechargeCenterSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index aef62c997..fa1040d4c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -31,6 +31,10 @@ pub enum RuntimeProfileWalletLedgerSourceType { MembershipPeriodGrant, MembershipPeriodReset, + + DailyFreeGrant, + + DailyFreeReset, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f2d294872..0dc48da85 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -194,6 +194,7 @@ macro_rules! migration_tables { feature_gate_config, user_browse_history, profile_dashboard_state, + profile_daily_free_points, profile_wallet_ledger, asset_operation_wallet_settlement, profile_wallet_config, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 737dd319c..40ed154ee 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -26,6 +26,17 @@ pub struct ProfileDashboardState { pub(crate) updated_at: Timestamp, } +#[spacetimedb::table(accessor = profile_daily_free_points)] +#[derive(Clone)] +pub struct ProfileDailyFreePoints { + #[primary_key] + pub(crate) user_id: String, + pub(crate) day_key: i64, + pub(crate) granted_points: u64, + pub(crate) remaining_points: u64, + pub(crate) updated_at: Timestamp, +} + #[spacetimedb::table( accessor = profile_wallet_ledger, index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])), @@ -2039,16 +2050,67 @@ mod tests { assert_eq!(repeated.badge_label, ""); assert_eq!(repeated.description, "60泥点"); assert_eq!(untouched.product_id, "points_180"); - assert_eq!(untouched.bonus_points, 180); - assert_eq!(untouched.badge_label, "首充双倍"); - assert_eq!(untouched.description, "首充送180泥点"); + assert_eq!(untouched.bonus_points, 90); + assert_eq!(untouched.badge_label, "首充加赠"); + assert_eq!(untouched.description, "首充加赠90泥点"); + } + + #[test] + fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() { + let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); + let build_row = |points_amount: u64| ProfileRechargeProductConfig { + product_id: format!("points_{points_amount}"), + title: format!("{points_amount}泥点"), + price_cents: points_amount * 10, + kind: RuntimeProfileRechargeProductKind::Points, + points_amount, + bonus_points: points_amount, + duration_days: 0, + badge_label: "首充双倍".to_string(), + description: format!("首充送{points_amount}泥点"), + tier: RuntimeProfileMembershipTier::Normal, + enabled: true, + sort_order: 0, + created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + created_at: timestamp, + updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + updated_at: timestamp, + membership_period_points: 0, + membership_period_days: 0, + membership_queue_limit: 0, + membership_discount_bps: 0, + }; + + assert_eq!( + resolve_default_point_product_migration(&build_row(180)), + Some(DefaultPointProductMigration { + bonus_points: 90, + badge_label: "首充加赠", + description: "首充加赠90泥点", + enabled: true, + }), + ); + assert_eq!( + resolve_default_point_product_migration(&build_row(1_280)), + Some(DefaultPointProductMigration { + bonus_points: 1_280, + badge_label: "首充双倍", + description: "", + enabled: false, + }), + ); + + let mut admin_row = build_row(300); + admin_row.updated_by = "admin-1".to_string(); + assert_eq!(resolve_default_point_product_migration(&admin_row), None); } #[test] fn membership_wallet_split_metadata_keeps_cycle_reset_for_refund_restore() { - let metadata = metadata_with_membership_wallet_consumption_split( + let metadata = metadata_with_profile_wallet_consumption_split( r#"{"externalGenerationJobId":"job-1"}"#, 150, + DailyFreePointMutation::none(), MembershipCyclePointMutation { points: 120, cycle_resets_at_micros: Some(123_000), @@ -2062,6 +2124,12 @@ mod tests { .and_then(JsonValue::as_str), Some("job-1"), ); + assert_eq!( + parsed + .get("dailyFreePointsDelta") + .and_then(JsonValue::as_i64), + Some(0), + ); assert_eq!( parsed .get("membershipPeriodPointsDelta") @@ -2089,6 +2157,190 @@ mod tests { ); } + #[test] + fn wallet_split_consumes_daily_free_before_membership_and_permanent_points() { + let metadata = metadata_with_profile_wallet_consumption_split( + "{}", + 150, + DailyFreePointMutation { + points: 20, + day_key: Some(20_280), + }, + MembershipCyclePointMutation { + points: 120, + cycle_resets_at_micros: Some(456_000), + }, + ); + let parsed = serde_json::from_str::(&metadata).expect("metadata json"); + + assert_eq!(parsed["dailyFreePointsDelta"], json!(-20)); + assert_eq!(parsed["dailyFreeDayKey"], json!(20_280)); + assert_eq!(parsed["membershipPeriodPointsDelta"], json!(-120)); + assert_eq!(parsed["permanentPointsDelta"], json!(-10)); + assert_eq!( + daily_free_refund_restore_candidate_from_consume_metadata(&metadata), + DailyFreePointMutation { + points: 20, + day_key: Some(20_280), + }, + ); + } + + #[test] + fn cross_day_daily_free_refund_keeps_the_original_permanent_remainder_permanent() { + let metadata = metadata_with_profile_wallet_refund_split( + "{}", + 30, + DailyFreePointMutation { + points: 20, + day_key: Some(20_281), + }, + MembershipCyclePointMutation::none(), + ); + let parsed = serde_json::from_str::(&metadata).expect("metadata json"); + + assert_eq!(parsed["dailyFreePointsDelta"], json!(20)); + assert_eq!(parsed["dailyFreeDayKey"], json!(20_281)); + assert_eq!(parsed["membershipPeriodPointsDelta"], json!(0)); + assert_eq!(parsed["permanentPointsDelta"], json!(10)); + } + + #[test] + fn daily_free_refund_restore_candidate_requires_a_negative_delta_and_day_key() { + assert_eq!( + daily_free_refund_restore_candidate_from_consume_metadata( + r#"{"dailyFreePointsDelta":-20,"dailyFreeDayKey":20280}"#, + ), + DailyFreePointMutation { + points: 20, + day_key: Some(20_280), + }, + ); + assert_eq!( + daily_free_refund_restore_candidate_from_consume_metadata( + r#"{"dailyFreePointsDelta":0,"dailyFreeDayKey":20280}"#, + ), + DailyFreePointMutation::none(), + ); + assert_eq!( + daily_free_refund_restore_candidate_from_consume_metadata("not-json"), + DailyFreePointMutation::none(), + ); + } + + #[test] + fn daily_free_refund_restores_same_day_and_stacks_after_cross_day() { + assert_eq!( + resolve_daily_free_refund_restore_plan(20_280, 20_280, 20_280, 20, 5, 20, 20), + Some(DailyFreeRefundRestorePlan { + restored_points: 15, + target_day_key: 20_280, + granted_points_delta: 0, + }), + ); + assert_eq!( + resolve_daily_free_refund_restore_plan(20_280, 20_281, 20_281, 20, 20, 20, 20), + Some(DailyFreeRefundRestorePlan { + restored_points: 20, + target_day_key: 20_281, + granted_points_delta: 20, + }), + ); + assert_eq!( + resolve_daily_free_refund_restore_plan(20_279, 20_281, 20_281, 40, 40, 20, 20), + Some(DailyFreeRefundRestorePlan { + restored_points: 20, + target_day_key: 20_281, + granted_points_delta: 20, + }), + ); + assert_eq!( + resolve_daily_free_refund_restore_plan(20_281, 20_281, 20_281, 60, 50, 10, 10), + Some(DailyFreeRefundRestorePlan { + restored_points: 10, + target_day_key: 20_281, + granted_points_delta: 0, + }), + ); + assert_eq!( + resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282), + Some(DailyFreeRefreshPlan { + expired_points: 60, + granted_points: 20, + reset: true, + }), + ); + assert_eq!( + resolve_daily_free_refund_restore_plan(20_282, 20_281, 20_281, 20, 20, 20, 20), + None, + ); + } + + #[test] + fn daily_free_reset_boundary_is_beijing_midnight() { + let day_key = 20_280; + let resets_at_micros = profile_daily_free_points_resets_at_micros(day_key); + + assert_eq!(PROFILE_DAILY_FREE_POINTS_PER_DAY, 20); + assert_eq!( + runtime_profile_beijing_day_key(resets_at_micros.saturating_sub(1)), + day_key, + ); + assert_eq!( + runtime_profile_beijing_day_key(resets_at_micros), + day_key + 1, + ); + } + + #[test] + fn daily_free_refresh_plan_grants_once_and_replaces_cross_day_remainder() { + assert_eq!( + resolve_daily_free_refresh_plan(None, 0, 20_280), + Some(DailyFreeRefreshPlan { + expired_points: 0, + granted_points: 20, + reset: false, + }), + ); + assert_eq!( + resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280), + None, + ); + assert_eq!( + resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281), + Some(DailyFreeRefreshPlan { + expired_points: 7, + granted_points: 20, + reset: true, + }), + ); + assert_eq!( + resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280), + None, + ); + } + + #[test] + fn legacy_wallet_snapshot_keeps_current_daily_free_points() { + assert_eq!( + merge_legacy_wallet_balance_with_daily_free_points(100, 20), + 120, + ); + assert_eq!( + merge_legacy_wallet_balance_with_daily_free_points(100, 0), + 100, + ); + assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( + RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, + )); + assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( + RuntimeProfileWalletLedgerSourceType::DailyFreeReset, + )); + assert!(profile_wallet_ledger_source_blocks_legacy_snapshot_sync( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + )); + } + #[test] fn membership_refund_restore_candidate_requires_negative_cycle_delta() { assert_eq!( @@ -2440,6 +2692,7 @@ fn sync_profile_dashboard_from_snapshot( game_state: Option<&serde_json::Map>, saved_at: Timestamp, ) { + refresh_profile_daily_free_points(ctx, &snapshot.user_id, ctx.timestamp); let current_state = ctx .db .profile_dashboard_state() @@ -2453,6 +2706,13 @@ fn sync_profile_dashboard_from_snapshot( .as_ref() .map(|row| row.total_play_time_ms) .unwrap_or(0); + let daily_free_remaining_points = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&snapshot.user_id) + .map(|row| row.remaining_points) + .unwrap_or(0); let has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id); let synced_wallet_balance = if has_business_wallet_ledger { None @@ -2460,6 +2720,12 @@ fn sync_profile_dashboard_from_snapshot( game_state .and_then(|state| state.get("playerCurrency")) .map(|value| module_runtime::read_runtime_json_non_negative_u64(Some(value))) + .map(|legacy_balance| { + merge_legacy_wallet_balance_with_daily_free_points( + legacy_balance, + daily_free_remaining_points, + ) + }) }; let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance); let mut next_total_play_time_ms = previous_total_play_time_ms; @@ -2648,7 +2914,7 @@ fn get_profile_dashboard_snapshot( ) -> Result { let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) .map_err(|error| error.to_string())?; - refresh_profile_membership_cycle(ctx, &validated_input.user_id, ctx.timestamp); + refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); let state = ctx .db .profile_dashboard_state() @@ -2660,6 +2926,8 @@ fn get_profile_dashboard_snapshot( .by_profile_played_world_user_id() .filter(&validated_input.user_id) .count() as u32; + let daily_free_points = + build_profile_daily_free_points_snapshot(ctx, &validated_input.user_id, ctx.timestamp); Ok(match state { Some(existing) => RuntimeProfileDashboardSnapshot { @@ -2668,6 +2936,7 @@ fn get_profile_dashboard_snapshot( total_play_time_ms: existing.total_play_time_ms, played_world_count, updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()), + daily_free_points, }, None => RuntimeProfileDashboardSnapshot { user_id: validated_input.user_id, @@ -2675,6 +2944,7 @@ fn get_profile_dashboard_snapshot( total_play_time_ms: 0, played_world_count, updated_at_micros: None, + daily_free_points, }, }) } @@ -2685,7 +2955,7 @@ fn list_profile_wallet_ledger_entries( ) -> Result, String> { let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id) .map_err(|error| error.to_string())?; - refresh_profile_membership_cycle(ctx, &validated_input.user_id, ctx.timestamp); + refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); let mut entries = ctx .db @@ -3908,7 +4178,7 @@ fn build_profile_recharge_center_snapshot( user_id: &str, ) -> RuntimeProfileRechargeCenterSnapshot { ensure_default_profile_recharge_product_config(ctx); - refresh_profile_membership_cycle(ctx, user_id, ctx.timestamp); + refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); let wallet_balance = ctx .db .profile_dashboard_state() @@ -3947,6 +4217,7 @@ fn build_profile_recharge_center_snapshot( latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), has_points_recharged, + daily_free_points: build_profile_daily_free_points_snapshot(ctx, user_id, ctx.timestamp), } } @@ -4352,7 +4623,7 @@ fn build_profile_task_center_snapshot( updated_at: Timestamp, ) -> Result { ensure_default_profile_task_config(ctx); - refresh_profile_membership_cycle(ctx, user_id, updated_at); + refresh_profile_wallet_expiring_points(ctx, user_id, updated_at); let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch()); let mut configs = ctx.db.profile_task_config().iter().collect::>(); configs.sort_by(|left, right| { @@ -4748,6 +5019,7 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { if ctx.db.profile_recharge_product_config().count() > 0 { + migrate_legacy_default_profile_recharge_product_config(ctx); return; } @@ -4784,6 +5056,97 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DefaultPointProductMigration { + bonus_points: u64, + badge_label: &'static str, + description: &'static str, + enabled: bool, +} + +fn resolve_default_point_product_migration( + row: &ProfileRechargeProductConfig, +) -> Option { + if row.kind != RuntimeProfileRechargeProductKind::Points + || row.tier != RuntimeProfileMembershipTier::Normal + || row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID + || !row.enabled + || row.duration_days != 0 + || row.bonus_points != row.points_amount + || row.badge_label != "首充双倍" + || row.description != format!("首充送{}泥点", row.points_amount) + { + return None; + } + + let expected_product_id = format!("points_{}", row.points_amount); + let expected_title = format!("{}泥点", row.points_amount); + if row.product_id != expected_product_id + || row.title != expected_title + || row.price_cents != row.points_amount.saturating_mul(10) + { + return None; + } + + match row.points_amount { + 60 => Some(DefaultPointProductMigration { + bonus_points: 0, + badge_label: "", + description: "60泥点", + enabled: true, + }), + 180 => Some(DefaultPointProductMigration { + bonus_points: 90, + badge_label: "首充加赠", + description: "首充加赠90泥点", + enabled: true, + }), + 300 => Some(DefaultPointProductMigration { + bonus_points: 150, + badge_label: "首充加赠", + description: "首充加赠150泥点", + enabled: true, + }), + 680 => Some(DefaultPointProductMigration { + bonus_points: 340, + badge_label: "首充加赠", + description: "首充加赠340泥点", + enabled: true, + }), + 1_280 | 3_280 => Some(DefaultPointProductMigration { + bonus_points: row.bonus_points, + badge_label: "首充双倍", + description: "", + enabled: false, + }), + _ => None, + } +} + +fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { + let rows = ctx + .db + .profile_recharge_product_config() + .iter() + .collect::>(); + for mut row in rows { + let Some(migration) = resolve_default_point_product_migration(&row) else { + continue; + }; + row.bonus_points = migration.bonus_points; + row.badge_label = migration.badge_label.to_string(); + if !migration.description.is_empty() { + row.description = migration.description.to_string(); + } + row.enabled = migration.enabled; + row.updated_at = ctx.timestamp; + ctx.db + .profile_recharge_product_config() + .product_id() + .update(row); + } +} + fn profile_recharge_product_config_rows( ctx: &ReducerContext, include_disabled: bool, @@ -4861,6 +5224,21 @@ impl MembershipCyclePointMutation { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DailyFreePointMutation { + points: u64, + day_key: Option, +} + +impl DailyFreePointMutation { + fn none() -> Self { + Self { + points: 0, + day_key: None, + } + } +} + struct MembershipCycleInitialization { row: ProfileMembership, granted_points_delta: u64, @@ -5121,7 +5499,7 @@ fn membership_cycle_metadata( .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } -fn update_profile_wallet_balance_for_membership_cycle( +fn update_profile_wallet_balance_for_expiring_points( ctx: &ReducerContext, user_id: &str, expired_points: u64, @@ -5194,6 +5572,162 @@ fn update_profile_wallet_balance_for_membership_cycle( }); } +fn daily_free_points_metadata( + action: &str, + expired_points: u64, + granted_points: u64, + day_key: i64, +) -> String { + serde_json::to_string(&json!({ + "action": action, + "dailyFreeDayKey": day_key, + "expiredDailyFreePoints": expired_points, + "grantedDailyFreePoints": granted_points, + })) + .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DailyFreeRefreshPlan { + expired_points: u64, + granted_points: u64, + reset: bool, +} + +fn resolve_daily_free_refresh_plan( + current_day_key: Option, + current_remaining_points: u64, + day_key: i64, +) -> Option { + match current_day_key { + Some(current_day_key) if current_day_key >= day_key => None, + Some(_) => Some(DailyFreeRefreshPlan { + expired_points: current_remaining_points, + granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + reset: true, + }), + None => Some(DailyFreeRefreshPlan { + expired_points: 0, + granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + reset: false, + }), + } +} + +fn refresh_profile_daily_free_points(ctx: &ReducerContext, user_id: &str, now: Timestamp) { + let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); + let current = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()); + let Some(plan) = resolve_daily_free_refresh_plan( + current.as_ref().map(|row| row.day_key), + current + .as_ref() + .map(|row| row.remaining_points) + .unwrap_or(0), + day_key, + ) else { + return; + }; + + match current { + Some(row) => { + debug_assert!(plan.reset); + ctx.db + .profile_daily_free_points() + .user_id() + .update(ProfileDailyFreePoints { + day_key, + granted_points: plan.granted_points, + remaining_points: plan.granted_points, + updated_at: now, + ..row + }); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + plan.expired_points, + plan.granted_points, + RuntimeProfileWalletLedgerSourceType::DailyFreeReset, + &format!("daily-free-reset:{user_id}:{day_key}"), + now, + daily_free_points_metadata( + "reset", + plan.expired_points, + plan.granted_points, + day_key, + ), + ); + } + None => { + debug_assert!(!plan.reset); + ctx.db + .profile_daily_free_points() + .insert(ProfileDailyFreePoints { + user_id: user_id.to_string(), + day_key, + granted_points: plan.granted_points, + remaining_points: plan.granted_points, + updated_at: now, + }); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + plan.granted_points, + RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, + &format!("daily-free-grant:{user_id}:{day_key}"), + now, + daily_free_points_metadata("grant", 0, plan.granted_points, day_key), + ); + } + } +} + +fn profile_daily_free_points_resets_at_micros(day_key: i64) -> i64 { + day_key + .saturating_add(1) + .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) + .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS) +} + +fn build_profile_daily_free_points_snapshot( + ctx: &ReducerContext, + user_id: &str, + now: Timestamp, +) -> RuntimeProfileDailyFreePointsSnapshot { + let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); + ctx.db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()) + .map(|row| RuntimeProfileDailyFreePointsSnapshot { + day_key: row.day_key, + granted_points: row.granted_points, + remaining_points: row.remaining_points, + resets_at_micros: profile_daily_free_points_resets_at_micros(row.day_key), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) + .unwrap_or(RuntimeProfileDailyFreePointsSnapshot { + day_key, + granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + remaining_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + resets_at_micros: profile_daily_free_points_resets_at_micros(day_key), + updated_at_micros: now.to_micros_since_unix_epoch(), + }) +} + +fn refresh_profile_wallet_expiring_points( + ctx: &ReducerContext, + user_id: &str, + membership_now: Timestamp, +) { + refresh_profile_daily_free_points(ctx, user_id, ctx.timestamp); + refresh_profile_membership_cycle(ctx, user_id, membership_now); +} + fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) { let Some(mut row) = ctx .db @@ -5214,7 +5748,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti row.updated_at = now; let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_membership_cycle( + update_profile_wallet_balance_for_expiring_points( ctx, user_id, expired_points, @@ -5240,7 +5774,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti row = initialization.row; let cycle_resets_at = row.cycle_resets_at.clone(); upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_membership_cycle( + update_profile_wallet_balance_for_expiring_points( ctx, user_id, 0, @@ -5294,7 +5828,7 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch(); let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}"); upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_membership_cycle( + update_profile_wallet_balance_for_expiring_points( ctx, user_id, expired_points, @@ -5310,7 +5844,7 @@ fn build_profile_membership_snapshot( ctx: &ReducerContext, user_id: &str, ) -> RuntimeProfileMembershipSnapshot { - refresh_profile_membership_cycle(ctx, user_id, ctx.timestamp); + refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let membership = ctx .db @@ -5376,7 +5910,7 @@ fn apply_profile_membership_purchase( product: &RuntimeProfileRechargeProductSnapshot, purchased_at: Timestamp, ) -> Result { - refresh_profile_membership_cycle(ctx, user_id, purchased_at); + refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); let tier = product.tier; let duration_days = product.duration_days; let period_days = membership_product_period_days(product); @@ -5460,7 +5994,7 @@ fn apply_profile_membership_purchase( "membership-period-grant:{user_id}:{}:{}", purchased_at_micros, product.product_id ); - update_profile_wallet_balance_for_membership_cycle( + update_profile_wallet_balance_for_expiring_points( ctx, user_id, 0, @@ -5925,6 +6459,142 @@ fn consume_profile_membership_cycle_points( } } +fn consume_profile_daily_free_points( + ctx: &ReducerContext, + user_id: &str, + amount: u64, + consumed_at: Timestamp, +) -> DailyFreePointMutation { + if amount == 0 { + return DailyFreePointMutation::none(); + } + let day_key = runtime_profile_beijing_day_key(consumed_at.to_micros_since_unix_epoch()); + let Some(mut row) = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()) + else { + return DailyFreePointMutation::none(); + }; + if row.day_key != day_key || row.remaining_points == 0 { + return DailyFreePointMutation::none(); + } + + let consumed = row.remaining_points.min(amount); + row.remaining_points -= consumed; + row.updated_at = consumed_at; + ctx.db.profile_daily_free_points().user_id().update(row); + DailyFreePointMutation { + points: consumed, + day_key: Some(day_key), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DailyFreeRefundRestorePlan { + restored_points: u64, + target_day_key: i64, + granted_points_delta: u64, +} + +fn resolve_daily_free_refund_restore_plan( + consumed_day_key: i64, + current_day_key: i64, + row_day_key: i64, + granted_points: u64, + remaining_points: u64, + candidate_points: u64, + refund_amount: u64, +) -> Option { + if candidate_points == 0 || refund_amount == 0 || row_day_key != current_day_key { + return None; + } + + let candidate_points = candidate_points.min(refund_amount); + if consumed_day_key == current_day_key { + let restored_points = candidate_points.min(granted_points.saturating_sub(remaining_points)); + return (restored_points > 0).then_some(DailyFreeRefundRestorePlan { + restored_points, + target_day_key: current_day_key, + granted_points_delta: 0, + }); + } + if consumed_day_key > current_day_key { + return None; + } + + Some(DailyFreeRefundRestorePlan { + restored_points: candidate_points, + target_day_key: current_day_key, + granted_points_delta: candidate_points, + }) +} + +fn restore_profile_daily_free_points_for_refund( + ctx: &ReducerContext, + user_id: &str, + amount: u64, + refund_ledger_id: &str, + refunded_at: Timestamp, +) -> DailyFreePointMutation { + if amount == 0 { + return DailyFreePointMutation::none(); + } + let Some(consume_ledger_id) = refund_ledger_id + .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) + .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) + else { + return DailyFreePointMutation::none(); + }; + let Some(consume_ledger) = ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&consume_ledger_id) + else { + return DailyFreePointMutation::none(); + }; + let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else { + return DailyFreePointMutation::none(); + }; + let restore_candidate = + daily_free_refund_restore_candidate_from_consume_metadata(metadata_json); + let Some(expected_day_key) = restore_candidate.day_key else { + return DailyFreePointMutation::none(); + }; + let current_day_key = runtime_profile_beijing_day_key(refunded_at.to_micros_since_unix_epoch()); + + let Some(mut row) = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()) + else { + return DailyFreePointMutation::none(); + }; + let Some(plan) = resolve_daily_free_refund_restore_plan( + expected_day_key, + current_day_key, + row.day_key, + row.granted_points, + row.remaining_points, + restore_candidate.points, + amount, + ) else { + return DailyFreePointMutation::none(); + }; + + row.granted_points = row.granted_points.saturating_add(plan.granted_points_delta); + row.remaining_points = row.remaining_points.saturating_add(plan.restored_points); + row.updated_at = refunded_at; + ctx.db.profile_daily_free_points().user_id().update(row); + DailyFreePointMutation { + points: plan.restored_points, + day_key: Some(plan.target_day_key), + } +} + fn restore_profile_membership_cycle_points_for_refund( ctx: &ReducerContext, user_id: &str, @@ -6024,10 +6694,34 @@ fn membership_refund_restore_candidate_from_consume_metadata( } } -fn metadata_with_membership_wallet_delta_split( +fn daily_free_refund_restore_candidate_from_consume_metadata( metadata_json: &str, +) -> DailyFreePointMutation { + let Some(metadata) = serde_json::from_str::(metadata_json) + .ok() + .filter(JsonValue::is_object) + else { + return DailyFreePointMutation::none(); + }; + let daily_free_delta = metadata + .get("dailyFreePointsDelta") + .and_then(JsonValue::as_i64) + .unwrap_or(0); + if daily_free_delta >= 0 { + return DailyFreePointMutation::none(); + } + DailyFreePointMutation { + points: daily_free_delta.unsigned_abs(), + day_key: metadata.get("dailyFreeDayKey").and_then(JsonValue::as_i64), + } +} + +fn metadata_with_profile_wallet_delta_split( + metadata_json: &str, + daily_free_delta: i64, membership_period_delta: i64, permanent_delta: i64, + daily_free_day_key: Option, cycle_resets_at_micros: Option, ) -> String { let mut metadata = serde_json::from_str::(metadata_json) @@ -6035,11 +6729,15 @@ fn metadata_with_membership_wallet_delta_split( .filter(JsonValue::is_object) .unwrap_or_else(|| json!({})); if let Some(object) = metadata.as_object_mut() { + object.insert("dailyFreePointsDelta".to_string(), json!(daily_free_delta)); object.insert( "membershipPeriodPointsDelta".to_string(), json!(membership_period_delta), ); object.insert("permanentPointsDelta".to_string(), json!(permanent_delta)); + if let Some(daily_free_day_key) = daily_free_day_key { + object.insert("dailyFreeDayKey".to_string(), json!(daily_free_day_key)); + } if let Some(cycle_resets_at_micros) = cycle_resets_at_micros { object.insert( "cycleResetsAtMicros".to_string(), @@ -6050,38 +6748,46 @@ fn metadata_with_membership_wallet_delta_split( serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string()) } -fn metadata_with_membership_wallet_consumption_split( +fn metadata_with_profile_wallet_consumption_split( metadata_json: &str, total_consumed: u64, + daily_free_consumed: DailyFreePointMutation, membership_period_consumed: MembershipCyclePointMutation, ) -> String { if total_consumed == 0 { return metadata_json.to_string(); } - let membership_points = membership_period_consumed.points; - let permanent_consumed = total_consumed.saturating_sub(membership_points); - metadata_with_membership_wallet_delta_split( + let permanent_consumed = total_consumed + .saturating_sub(daily_free_consumed.points) + .saturating_sub(membership_period_consumed.points); + metadata_with_profile_wallet_delta_split( metadata_json, - -(membership_points as i64), + -(daily_free_consumed.points as i64), + -(membership_period_consumed.points as i64), -(permanent_consumed as i64), + daily_free_consumed.day_key, membership_period_consumed.cycle_resets_at_micros, ) } -fn metadata_with_membership_wallet_refund_split( +fn metadata_with_profile_wallet_refund_split( metadata_json: &str, total_refunded: u64, + daily_free_refunded: DailyFreePointMutation, membership_period_refunded: MembershipCyclePointMutation, ) -> String { if total_refunded == 0 { return metadata_json.to_string(); } - let membership_points = membership_period_refunded.points; - let permanent_refunded = total_refunded.saturating_sub(membership_points); - metadata_with_membership_wallet_delta_split( + let permanent_refunded = total_refunded + .saturating_sub(daily_free_refunded.points) + .saturating_sub(membership_period_refunded.points); + metadata_with_profile_wallet_delta_split( metadata_json, - membership_points as i64, + daily_free_refunded.points as i64, + membership_period_refunded.points as i64, permanent_refunded as i64, + daily_free_refunded.day_key, membership_period_refunded.cycle_resets_at_micros, ) } @@ -6096,7 +6802,8 @@ fn apply_profile_wallet_signed_delta( idempotent: bool, metadata_json: &str, ) -> Result { - refresh_profile_membership_cycle(ctx, user_id, created_at); + let settled_at = ctx.timestamp; + refresh_profile_wallet_expiring_points(ctx, user_id, settled_at); if idempotent { if let Some(existing) = ctx .db @@ -6126,41 +6833,71 @@ fn apply_profile_wallet_signed_delta( .as_ref() .map(|row| row.created_at) .unwrap_or(created_at); + let daily_free_consumed = if amount_delta < 0 + && !matches!( + source_type, + RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset + | RuntimeProfileWalletLedgerSourceType::DailyFreeReset + ) { + consume_profile_daily_free_points(ctx, user_id, amount_delta.unsigned_abs(), settled_at) + } else { + DailyFreePointMutation::none() + }; let membership_period_consumed = if amount_delta < 0 - && source_type != RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset - { + && !matches!( + source_type, + RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset + | RuntimeProfileWalletLedgerSourceType::DailyFreeReset + ) { consume_profile_membership_cycle_points( ctx, user_id, - amount_delta.unsigned_abs(), - created_at, + amount_delta + .unsigned_abs() + .saturating_sub(daily_free_consumed.points), + settled_at, ) } else { MembershipCyclePointMutation::none() }; + let daily_free_refunded = if amount_delta > 0 + && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund + { + restore_profile_daily_free_points_for_refund( + ctx, + user_id, + amount_delta as u64, + ledger_id, + settled_at, + ) + } else { + DailyFreePointMutation::none() + }; let membership_period_refunded = if amount_delta > 0 && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund { restore_profile_membership_cycle_points_for_refund( ctx, user_id, - amount_delta as u64, + (amount_delta as u64).saturating_sub(daily_free_refunded.points), ledger_id, - created_at, + settled_at, ) } else { MembershipCyclePointMutation::none() }; let ledger_metadata_json = if amount_delta < 0 { - metadata_with_membership_wallet_consumption_split( + metadata_with_profile_wallet_consumption_split( metadata_json, amount_delta.unsigned_abs(), + daily_free_consumed, membership_period_consumed, ) } else if amount_delta > 0 { - metadata_with_membership_wallet_refund_split( + metadata_with_profile_wallet_refund_split( metadata_json, amount_delta as u64, + daily_free_refunded, membership_period_refunded, ) } else { @@ -6256,10 +6993,28 @@ fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bo .filter(user_id) .any(|row| { row.user_id == user_id - && row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync + && profile_wallet_ledger_source_blocks_legacy_snapshot_sync(row.source_type) }) } +fn profile_wallet_ledger_source_blocks_legacy_snapshot_sync( + source_type: RuntimeProfileWalletLedgerSourceType, +) -> bool { + !matches!( + source_type, + RuntimeProfileWalletLedgerSourceType::SnapshotSync + | RuntimeProfileWalletLedgerSourceType::DailyFreeGrant + | RuntimeProfileWalletLedgerSourceType::DailyFreeReset + ) +} + +fn merge_legacy_wallet_balance_with_daily_free_points( + legacy_balance: u64, + daily_free_remaining_points: u64, +) -> u64 { + legacy_balance.saturating_add(daily_free_remaining_points) +} + fn latest_profile_recharge_order( ctx: &ReducerContext, user_id: &str, diff --git a/src/components/common/PlatformMudPointWalletEntry.test.tsx b/src/components/common/PlatformMudPointWalletEntry.test.tsx new file mode 100644 index 000000000..6aadc243b --- /dev/null +++ b/src/components/common/PlatformMudPointWalletEntry.test.tsx @@ -0,0 +1,82 @@ +/* @vitest-environment jsdom */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import { PlatformMudPointWalletEntry } from './PlatformMudPointWalletEntry'; +import { formatMudPointCount } from './platformMudPointWalletModel'; + +const breakdown = { + totalPoints: 207, + permanentPoints: 100, + limitedPoints: 80, + limitedExpiresAt: '2026-07-06T16:00:00Z', + dailyFreePoints: 27, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', +}; + +test('formats mud point counts consistently', () => { + expect(formatMudPointCount(12_345)).toBe('12,345'); + expect(formatMudPointCount(12_345, true)).toBe('1.2万'); +}); + +test('shows only permanent and daily free points in the shared wallet panel', async () => { + const user = userEvent.setup(); + const onRequestDetails = vi.fn(); + const onRecharge = vi.fn(); + const onOpenLedger = vi.fn(); + + render( + , + ); + + const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + await user.hover(balanceButton); + + const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + expect(details.className).toContain('rounded-[1.12rem]'); + expect(within(details).getByText('不限时泥点')).toBeTruthy(); + expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy(); + expect(within(details).getByText('100')).toBeTruthy(); + expect(within(details).queryByText('限时泥点')).toBeNull(); + expect(within(details).queryByText('2026-07-07 到期')).toBeNull(); + expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + expect(within(details).getByText('27')).toBeTruthy(); + expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy(); + expect(onRequestDetails).not.toHaveBeenCalled(); + + await user.click(within(details).getByRole('button', { name: '使用详情' })); + expect(onOpenLedger).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '充值' })); + expect(onRecharge).toHaveBeenCalledTimes(1); +}); + +test('requests the balance breakdown when a compact entry opens', async () => { + const user = userEvent.setup(); + const onRequestDetails = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: '泥点 20' })); + expect(onRequestDetails).toHaveBeenCalledTimes(1); + expect(screen.getByText('余额明细暂不可用')).toBeTruthy(); +}); diff --git a/src/components/common/PlatformMudPointWalletEntry.tsx b/src/components/common/PlatformMudPointWalletEntry.tsx new file mode 100644 index 000000000..9e74b519e --- /dev/null +++ b/src/components/common/PlatformMudPointWalletEntry.tsx @@ -0,0 +1,255 @@ +import { ChevronRight, ReceiptText } from 'lucide-react'; +import { + type FocusEvent, + type MouseEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; +import { formatMudPointCount } from './platformMudPointWalletModel'; + +const MUD_POINT_ICON_SRC = '/creation-home/topbar-wallet.png'; + +export type PlatformMudPointWalletEntryProps = { + balance: number | null; + breakdown?: ProfileMudPointBalance | null; + isLoading?: boolean; + error?: string | null; + variant?: 'desktop' | 'mobile' | 'editor'; + className?: string; + onRequestDetails: () => void; + onRecharge: () => void; + onOpenLedger: () => void; +}; + +function MudPointBalanceRow({ + label, + points, + detail, +}: { + label: string; + points: number; + detail?: string | null; +}) { + return ( +
+
+
+ {label} +
+ {detail ? ( +
+ {detail} +
+ ) : null} +
+
+ {formatMudPointCount(points)} +
+
+ ); +} + +export function PlatformMudPointWalletEntry({ + balance, + breakdown, + isLoading = false, + variant = 'desktop', + className, + onRequestDetails, + onRecharge, + onOpenLedger, +}: PlatformMudPointWalletEntryProps) { + const rootRef = useRef(null); + const isOpenRef = useRef(false); + const [isOpen, setIsOpen] = useState(false); + const isCompact = variant === 'mobile'; + const displayedBalance = breakdown?.totalPoints ?? balance; + const balanceLabel = + displayedBalance === null + ? '--' + : formatMudPointCount(displayedBalance, true); + const exactBalanceLabel = + displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); + + const closeDetails = useCallback(() => { + isOpenRef.current = false; + setIsOpen(false); + }, []); + + const requestAndOpen = useCallback(() => { + if (isOpenRef.current) { + return; + } + isOpenRef.current = true; + setIsOpen(true); + if (!breakdown && !isLoading) { + onRequestDetails(); + } + }, [breakdown, isLoading, onRequestDetails]); + + useEffect(() => { + if (!isOpen) { + return; + } + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + closeDetails(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeDetails(); + } + }; + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [closeDetails, isOpen]); + + const closeAfterFocusLeaves = (event: FocusEvent) => { + const nextTarget = event.relatedTarget; + if ( + !(nextTarget instanceof Node) || + !event.currentTarget.contains(nextTarget) + ) { + closeDetails(); + } + }; + const closeAfterPointerLeaves = (event: MouseEvent) => { + const nextTarget = event.relatedTarget; + if ( + nextTarget instanceof Node && + event.currentTarget.contains(nextTarget) + ) { + return; + } + window.setTimeout(() => { + if ( + rootRef.current && + !rootRef.current.contains(document.activeElement) + ) { + closeDetails(); + } + }, 120); + }; + return ( +
+
+ +
+ + {isOpen ? ( +
+
+
+ 泥点 {exactBalanceLabel} +
+ +
+ + {breakdown ? ( + <> + + + + ) : ( +
+ {isLoading ? '余额读取中' : '余额明细暂不可用'} +
+ )} + + +
+ ) : null} +
+ ); +} diff --git a/src/components/common/platformMudPointWalletModel.ts b/src/components/common/platformMudPointWalletModel.ts new file mode 100644 index 000000000..0236383c0 --- /dev/null +++ b/src/components/common/platformMudPointWalletModel.ts @@ -0,0 +1,10 @@ +export function formatMudPointCount(value: number, compact = false) { + const normalizedValue = Math.max(0, Math.round(value)); + if (compact && normalizedValue >= 100_000_000) { + return `${(normalizedValue / 100_000_000).toFixed(1)}亿`; + } + if (compact && normalizedValue >= 10_000) { + return `${(normalizedValue / 10_000).toFixed(1)}万`; + } + return normalizedValue.toLocaleString('zh-CN'); +} diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index bbfb494b6..48feebceb 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -99,8 +99,11 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { isProjectRenameSaving: false, projectRenameError: null, layers: [], - walletBalanceLabel: '0泥点', + walletBalance: 0, + walletBreakdown: null, isWalletBalanceLoading: false, + isWalletDetailsLoading: false, + walletDetailsError: null, currentUser: null, assetExportStatus: null, isExportingAssets: false, @@ -111,7 +114,9 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), - onOpenWallet: vi.fn(), + onRequestWalletDetails: vi.fn(), + onRecharge: vi.fn(), + onOpenWalletLedger: vi.fn(), onOpenAccount: vi.fn(), }; } diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 34cf5cd9f..ed9cf0dda 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -12,7 +12,6 @@ import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EditorAgentConversationClient } from './useEditorAgentConversation'; import { ApiClientError, AuthUiContext, @@ -25,6 +24,7 @@ import { readZipText, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; +import type { EditorAgentConversationClient } from './useEditorAgentConversation'; type EditorAgentListConversations = EditorAgentConversationClient['listConversations']; @@ -110,6 +110,8 @@ const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn()); +const getRpgProfileRechargeCenterMock = vi.hoisted(() => vi.fn()); +const getRpgProfileWalletLedgerMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -140,6 +142,17 @@ vi.mock('../../services/platform-entry/platformProfileClient', () => ({ getPlatformProfileDashboard: getPlatformProfileDashboardMock, })); +vi.mock('../../services/rpg-entry/rpgProfileClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/rpg-entry/rpgProfileClient') + >('../../services/rpg-entry/rpgProfileClient'); + return { + ...actual, + getRpgProfileRechargeCenter: getRpgProfileRechargeCenterMock, + getRpgProfileWalletLedger: getRpgProfileWalletLedgerMock, + }; +}); + vi.mock('../../services/frontendRuntimeConfigService', () => ({ loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock, })); @@ -276,6 +289,46 @@ describe('ImageCanvasEditorView', () => { playedWorldCount: 0, updatedAt: null, }); + getRpgProfileRechargeCenterMock.mockResolvedValue({ + walletBalance: 1234, + mudPointBalance: { + totalPoints: 1234, + permanentPoints: 1000, + limitedPoints: 214, + limitedExpiresAt: '2026-07-31T16:00:00Z', + dailyFreePoints: 20, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-07-12T16:00:00Z', + }, + membership: { + status: 'normal', + tier: 'normal', + startedAt: null, + expiresAt: null, + updatedAt: null, + cycleStartedAt: null, + cycleResetsAt: null, + cycleGrantedPoints: 0, + cycleRemainingPoints: 0, + cyclePeriodDays: 30, + }, + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }); + getRpgProfileWalletLedgerMock.mockResolvedValue({ + entries: [ + { + id: 'editor-ledger-1', + amountDelta: -5, + balanceAfter: 1234, + sourceType: 'asset_operation_consume', + createdAt: '2026-07-12T08:00:00Z', + }, + ], + }); }); afterEach(() => { @@ -285,6 +338,8 @@ describe('ImageCanvasEditorView', () => { deleteEditorAgentConversationMock.mockReset(); streamEditorAgentMessageMock.mockReset(); getPlatformProfileDashboardMock.mockReset(); + getRpgProfileRechargeCenterMock.mockReset(); + getRpgProfileWalletLedgerMock.mockReset(); loadFrontendRuntimeConfigMock.mockReset(); }); @@ -495,7 +550,7 @@ describe('ImageCanvasEditorView', () => { , ); - expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy(); + expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy(); expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ authImpact: 'local', skipRefresh: true, @@ -540,7 +595,7 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); - it('opens the account recharge entry from the canvas topbar mud point button', async () => { + it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => { render( { ); const walletButton = await screen.findByRole('button', { - name: '泥点余额 1,234泥点', + name: '泥点 1,234', }); fireEvent.click(walletButton); + const details = await screen.findByRole('dialog', { + name: '泥点账户详情', + }); + expect(within(details).getByText('不限时泥点')).toBeTruthy(); + expect(within(details).queryByText('限时泥点')).toBeNull(); + expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + + fireEvent.click(within(details).getByRole('button', { name: '使用详情' })); expect( - await screen.findByRole('dialog', { name: '账户充值' }), + await screen.findByRole('dialog', { name: '泥点账单' }), ).toBeTruthy(); - expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull(); + expect(getRpgProfileWalletLedgerMock).toHaveBeenCalledTimes(1); }); it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', async () => { @@ -662,11 +725,11 @@ describe('ImageCanvasEditorView', () => { const viewport = screen.getByLabelText('画布工作区'); fireEvent.click( await screen.findByRole('button', { - name: '泥点余额 1,234泥点', + name: '充值', }), ); expect( - await screen.findByRole('dialog', { name: '账户充值' }), + await screen.findByRole('dialog', { name: '购买更多泥点' }), ).toBeTruthy(); fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); @@ -692,7 +755,7 @@ describe('ImageCanvasEditorView', () => { ).toBe('true'); expect(viewport.getAttribute('aria-disabled')).toBe('true'); - fireEvent.click(screen.getByRole('button', { name: '关闭账户充值' })); + fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); const resumedToolbar = await screen.findByRole('toolbar', { name: '快速编辑框选工具', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 015198250..57e8b93a6 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -9,29 +9,29 @@ import { useState, } from 'react'; -import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent'; +import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService'; import { createEditorAsset, createEditorProjectResource, type EditorAssetSnapshot, type EditorProjectSnapshot, - loadEditorProject, loadEditorGenerationPricing, + loadEditorProject, } from '../../services/image-editor/editorProjectClient'; -import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService'; import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; +import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal'; import { PlatformRechargePaymentConfirmationMask, PlatformRechargePaymentResultDialog, } from '../platform-entry/PlatformRechargePaymentStatusDialogs'; import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController'; -import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, DEFAULT_CANVAS_BACKGROUND_COLOR, @@ -79,11 +79,11 @@ import { } from './useImageCanvasAssetCanvasBridge'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; +import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome'; import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface'; import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; -import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageController } from './useImageCanvasStageController'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; @@ -295,9 +295,7 @@ export function ImageCanvasEditorView({ }: ImageCanvasEditorViewProps = {}) { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); - const [walletBalanceLabel, setWalletBalanceLabel] = useState( - null, - ); + const [walletBalance, setWalletBalance] = useState(null); const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); @@ -479,37 +477,40 @@ export function ImageCanvasEditorView({ clearAuthOnUnauthorized: false, }) .then((dashboard) => { - setWalletBalanceLabel( - `${formatDashboardCount(dashboard.walletBalance)}泥点`, - ); + setWalletBalance(dashboard.walletBalance); }) .catch(() => undefined); }, []); const { - activeRechargeTab, buyRechargeProduct, closeNativeWechatPayment, confirmNativeWechatPayment, isLoadingRechargeCenter, + isLoadingWalletLedger, isRechargeOpen, isRewardCodeOpen, isSubmittingRewardCode, + isWalletLedgerOpen, loadRechargeCenter, + loadWalletLedger, nativeWechatPayment, openRechargeOrRewardCodeModal, + openWalletLedgerPanel, rechargeCenter, rechargeError, rechargePaymentResult, rewardCodeError, rewardCodeInput, rewardCodeSuccess, - setActiveRechargeTab, setIsRechargeOpen, setIsRewardCodeOpen, + setIsWalletLedgerOpen, setRechargePaymentResult, setRewardCodeInput, submittingRechargeProductId, submitRewardCode, + walletLedger, + walletLedgerError, wechatRechargeOrderConfirmationState, } = usePlatformProfileCenterController({ activeTab: 'editor-canvas', @@ -522,6 +523,7 @@ export function ImageCanvasEditorView({ const isAccountPaymentModalOpen = isRewardCodeOpen || isRechargeOpen || + isWalletLedgerOpen || Boolean(nativeWechatPayment) || Boolean(rechargePaymentResult) || Boolean(wechatRechargeOrderConfirmationState); @@ -541,7 +543,7 @@ export function ImageCanvasEditorView({ }, [authUi]); useEffect(() => { if (!authUi?.canAccessProtectedData || !authUi.user?.id) { - setWalletBalanceLabel(null); + setWalletBalance(null); setIsWalletBalanceLoading(false); return; } @@ -563,15 +565,13 @@ export function ImageCanvasEditorView({ if (!isMounted || currentRequestId !== requestId) { return; } - setWalletBalanceLabel( - `${formatDashboardCount(dashboard.walletBalance)}泥点`, - ); + setWalletBalance(dashboard.walletBalance); }) .catch(() => { if (!isMounted || currentRequestId !== requestId) { return; } - setWalletBalanceLabel(null); + setWalletBalance(null); }) .finally(() => { if (!isMounted || currentRequestId !== requestId) { @@ -2029,8 +2029,11 @@ export function ImageCanvasEditorView({ isProjectRenameSaving, projectRenameError, layers, - walletBalanceLabel, + walletBalance, + walletBreakdown: rechargeCenter?.mudPointBalance ?? null, isWalletBalanceLoading, + isWalletDetailsLoading: isLoadingRechargeCenter, + walletDetailsError: rechargeError, currentUser: authUi?.user, assetExportStatus, isExportingAssets, @@ -2041,7 +2044,9 @@ export function ImageCanvasEditorView({ resetProjectRenameError, exportCanvasAssets, onOpenShortcuts: () => setIsShortcutDialogOpen(true), - onOpenWallet: openAccountPaymentModal, + onRequestWalletDetails: loadRechargeCenter, + onRecharge: openAccountPaymentModal, + onOpenWalletLedger: openWalletLedgerPanel, onOpenAccount: () => { if (authUi?.user) { authUi.openAccountModal(); @@ -2231,8 +2236,6 @@ export function ImageCanvasEditorView({ error={rechargeError} submittingProductId={submittingRechargeProductId} nativePayment={nativeWechatPayment} - activeTab={activeRechargeTab} - onTabChange={setActiveRechargeTab} onClose={() => setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} @@ -2240,6 +2243,16 @@ export function ImageCanvasEditorView({ onCloseNativePayment={closeNativeWechatPayment} /> ) : null} + {isWalletLedgerOpen ? ( + setIsWalletLedgerOpen(false)} + onRetry={loadWalletLedger} + /> + ) : null} {rechargePaymentResult ? ( = {}): CanvasLayer { const id = overrides.id ?? 'layer-a'; return { @@ -27,9 +38,7 @@ function createLayer(overrides: Partial = {}): CanvasLayer { } function renderTopbar( - overrides: Partial< - Parameters[0] - > = {}, + overrides: Partial[0]> = {}, ) { useImageCanvasContextStore.getState().setProjectId('project-a'); const props: Parameters[0] = { @@ -39,8 +48,11 @@ function renderTopbar( isProjectRenameSaving: false, projectRenameError: null, layers: [], - walletBalanceLabel: '1,234泥点', + walletBalance: 1_234, + walletBreakdown, isWalletBalanceLoading: false, + isWalletDetailsLoading: false, + walletDetailsError: null, currentUser: null, assetExportStatus: null, isExportingAssets: false, @@ -51,7 +63,9 @@ function renderTopbar( resetProjectRenameError: vi.fn(), exportCanvasAssets: vi.fn(), onOpenShortcuts: vi.fn(), - onOpenWallet: vi.fn(), + onRequestWalletDetails: vi.fn(), + onRecharge: vi.fn(), + onOpenWalletLedger: vi.fn(), onOpenAccount: vi.fn(), ...overrides, }; @@ -80,22 +94,36 @@ describe('ImageCanvasTopbarView', () => { expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1); }); - it('shows the current mud point balance in the topbar', () => { + it('shows the shared mud point breakdown and wallet actions', async () => { + const user = userEvent.setup(); const props = renderTopbar({ - walletBalanceLabel: '1.2万泥点', + walletBalance: 12_345, + walletBreakdown: { + ...walletBreakdown, + totalPoints: 12_345, + }, }); const walletChip = screen.getByRole('button', { - name: '泥点余额 1.2万泥点', + name: '泥点 12,345', }); - expect(walletChip.textContent).toBe('1.2万泥点'); + expect(walletChip.textContent).toBe('泥点 1.2万'); expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( '/creation-home/topbar-wallet.png', ); - fireEvent.click(walletChip); + await user.hover(walletChip); + const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + expect(within(details).getByText('不限时泥点')).toBeTruthy(); + expect(within(details).queryByText('限时泥点')).toBeNull(); + expect(within(details).getByText('每日免费泥点')).toBeTruthy(); - expect(props.onOpenWallet).toHaveBeenCalledTimes(1); + await user.click(within(details).getByRole('button', { name: '使用详情' })); + expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: '充值' })); + expect(props.onRecharge).toHaveBeenCalledTimes(1); + expect(props.onRequestWalletDetails).not.toHaveBeenCalled(); }); it('shows the current user avatar beside the mud point balance', () => { @@ -151,8 +179,11 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[]} - walletBalanceLabel="0泥点" + walletBalance={0} + walletBreakdown={null} isWalletBalanceLoading={false} + isWalletDetailsLoading={false} + walletDetailsError={null} currentUser={null} assetExportStatus={null} isExportingAssets={false} @@ -163,14 +194,19 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} - onOpenWallet={vi.fn()} + onRequestWalletDetails={vi.fn()} + onRecharge={vi.fn()} + onOpenWalletLedger={vi.fn()} onOpenAccount={vi.fn()} />, ); expect( - (screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement) - .disabled, + ( + screen.getByRole('button', { + name: '下载画布素材', + }) as HTMLButtonElement + ).disabled, ).toBe(true); rerender( @@ -181,8 +217,11 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[createLayer()]} - walletBalanceLabel="0泥点" + walletBalance={0} + walletBreakdown={null} isWalletBalanceLoading={false} + isWalletDetailsLoading={false} + walletDetailsError={null} currentUser={null} assetExportStatus={{ tone: 'success', @@ -196,7 +235,9 @@ describe('ImageCanvasTopbarView', () => { resetProjectRenameError={vi.fn()} exportCanvasAssets={exportCanvasAssets} onOpenShortcuts={vi.fn()} - onOpenWallet={vi.fn()} + onRequestWalletDetails={vi.fn()} + onRecharge={vi.fn()} + onOpenWalletLedger={vi.fn()} onOpenAccount={vi.fn()} />, ); diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index d1698c9ce..72fa0e4ab 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -7,6 +7,8 @@ import { X, } from 'lucide-react'; +import type { ProfileMudPointBalance } from '../../../packages/shared/src/contracts/runtime'; +import { PlatformMudPointWalletEntry } from '../common/PlatformMudPointWalletEntry'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; @@ -21,8 +23,11 @@ export type ImageCanvasTopbarViewProps = { isProjectRenameSaving: boolean; projectRenameError: string | null; layers: CanvasLayer[]; - walletBalanceLabel: string | null; + walletBalance: number | null; + walletBreakdown?: ProfileMudPointBalance | null; isWalletBalanceLoading: boolean; + isWalletDetailsLoading: boolean; + walletDetailsError: string | null; currentUser: | { id: string; @@ -41,13 +46,13 @@ export type ImageCanvasTopbarViewProps = { resetProjectRenameError: () => void; exportCanvasAssets: () => void | Promise; onOpenShortcuts: () => void; - onOpenWallet: () => void; + onRequestWalletDetails: () => void; + onRecharge: () => void; + onOpenWalletLedger: () => void; onOpenAccount: () => void; }; -function buildCanvasUserCode( - user: ImageCanvasTopbarViewProps['currentUser'], -) { +function buildCanvasUserCode(user: ImageCanvasTopbarViewProps['currentUser']) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); } @@ -63,8 +68,11 @@ export function ImageCanvasTopbarView({ isProjectRenameSaving, projectRenameError, layers, - walletBalanceLabel, + walletBalance, + walletBreakdown, isWalletBalanceLoading, + isWalletDetailsLoading, + walletDetailsError, currentUser, assetExportStatus, isExportingAssets, @@ -75,17 +83,15 @@ export function ImageCanvasTopbarView({ resetProjectRenameError, exportCanvasAssets, onOpenShortcuts, - onOpenWallet, + onRequestWalletDetails, + onRecharge, + onOpenWalletLedger, onOpenAccount, }: ImageCanvasTopbarViewProps) { const projectId = useImageCanvasContextStore((state) => state.projectId); const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, ); - const walletDisplayLabel = walletBalanceLabel ?? '--泥点'; - const walletAriaLabel = walletBalanceLabel - ? `泥点余额 ${walletBalanceLabel}` - : '泥点余额读取中'; const userDisplayName = currentUser?.displayName?.trim() || '登录'; const userAvatarUrl = currentUser?.avatarUrl?.trim() || null; const userAvatarLabel = userDisplayName.slice(0, 1).toUpperCase(); @@ -198,22 +204,16 @@ export function ImageCanvasTopbarView({ {assetExportStatus.message} ) : null} - + -
- - @@ -4980,8 +4825,6 @@ export function RpgEntryHomeView({ error={rechargeError} submittingProductId={submittingRechargeProductId} nativePayment={nativeWechatPayment} - activeTab={activeRechargeTab} - onTabChange={setActiveRechargeTab} onClose={() => setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} @@ -5057,10 +4900,16 @@ export function RpgEntryHomeView({ const desktopTopbarActions: ReactNode = (
{isAuthenticated ? ( - ) : null}
+ {isMobileRecommendTab && isAuthenticated ? ( +
+ +
+ ) : null} {rechargePaymentConfirmationMask} ); @@ -5325,19 +5182,6 @@ export function RpgEntryHomeView({ {apiKeysModal} {rechargePaymentResultModal} {categoryFilterDialog} - {isTaskCenterOpen ? ( - setIsTaskCenterOpen(false)} - onRetry={loadTaskCenter} - onClaim={claimTaskReward} - /> - ) : null} {profilePopupPanel ? ( { }); expect( - buildWalletLedgerPresentation( - { entries: [incomeEntry, outcomeEntry] }, - 12, - ), + buildWalletLedgerPresentation({ entries: [incomeEntry, outcomeEntry] }, 12), ).toEqual({ balance: 80, balanceLabel: '80泥点', @@ -137,9 +134,20 @@ test('profile funds ViewModel builds wallet ledger presentation', () => { test('profile funds ViewModel formats recharge product and membership labels', () => { expect(formatRechargePrice(600)).toBe('¥6'); expect(formatRechargePrice(650)).toBe('¥6.50'); - expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe( - '60+60泥点', - ); + expect(buildRechargeProductValueLabel(buildRechargeProduct())).toBe('60泥点'); + expect( + buildRechargeProductValueLabel( + buildRechargeProduct({ + productId: 'points_180', + title: '180泥点', + priceCents: 1800, + pointsAmount: 180, + bonusPoints: 90, + badgeLabel: '首充加赠', + description: '首充加赠90泥点', + }), + ), + ).toBe('180+90泥点'); expect( buildRechargeProductValueLabel( buildRechargeProduct({ @@ -205,9 +213,7 @@ test('profile funds ViewModel formats recharge product and membership labels', ( cycleResetsAt: '2026-06-02T00:00:00.000Z', }), (value) => - value === '2026-06-03T00:00:00.000Z' - ? '06/03 08:00' - : '06/02 08:00', + value === '2026-06-03T00:00:00.000Z' ? '06/03 08:00' : '06/02 08:00', ), ).toBe('会员至 06/03 08:00 · 06/02 08:00重置'); expect( diff --git a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts index e871cdc11..ebf4f93b9 100644 --- a/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts +++ b/src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts @@ -13,6 +13,8 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { snapshot_sync: '账户同步', membership_period_grant: '会员周期发放', membership_period_reset: '会员周期重置', + daily_free_grant: '每日免费发放', + daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', asset_operation_refund: '资产操作退回', redeem_code_reward: '兑换码奖励', diff --git a/src/index.css b/src/index.css index c600b7fab..caa5a98a7 100644 --- a/src/index.css +++ b/src/index.css @@ -12774,6 +12774,8 @@ button.image-canvas-editor__reference-chip:disabled { .platform-desktop-shell--workbench .platform-desktop-topbar { display: flex; + z-index: 80; + overflow: visible; gap: 0.58rem; min-height: 3.62rem; border: 0; @@ -12898,16 +12900,11 @@ button.image-canvas-editor__reference-chip:disabled { display: none; } -.platform-desktop-shell--workbench .creation-home-topbar-art--wallet { - width: 1.32rem; - height: 1.32rem; - margin: -0.14rem -0.18rem -0.14rem -0.3rem; -} - -.platform-desktop-shell--workbench - .platform-desktop-create-wallet-chip - .platform-icon-badge { - display: none; +.platform-mobile-recommend-wallet-entry { + position: fixed; + top: max(0.75rem, calc(env(safe-area-inset-top, 0px) + 0.35rem)); + right: max(0.75rem, calc(env(safe-area-inset-right, 0px) + 0.35rem)); + z-index: 70; } .platform-desktop-shell--workbench @@ -13872,13 +13869,6 @@ button.image-canvas-editor__reference-chip:disabled { max-width: min(48vw, 10rem); } - .platform-mobile-create-wallet-chip > span:last-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .platform-mobile-create-wallet-chip, .platform-desktop-create-wallet-chip { min-width: 0; diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 3e221aad9..ce49fd4db 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -10,8 +10,8 @@ import type { PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, - ProfileRechargeOrder, ProfileRechargeCenterResponse, + ProfileRechargeOrder, ProfileReferralInviteCenterResponse, ProfileSaveArchiveListResponse, ProfileSaveArchiveResumeResponse, @@ -128,7 +128,7 @@ export function getRpgProfileRechargeCenter( return requestRpgRuntimeJson( '/profile/recharge-center', { method: 'GET' }, - '读取账户充值失败', + '读取泥点购买信息失败', options, ); }