From c7fe793a9e8dbbd3be646197ac5d45c8a86fa217 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 15 May 2026 06:11:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=85=85=E5=80=BC?= =?UTF-8?q?=E5=95=86=E5=93=81=E9=85=8D=E7=BD=AE=E5=92=8C=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E9=A6=96=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + apps/admin-web/src/api/adminApiClient.ts | 24 + apps/admin-web/src/api/adminApiTypes.ts | 40 ++ apps/admin-web/src/app/AdminApp.tsx | 14 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../src/pages/AdminRechargeProductPage.tsx | 451 ++++++++++++++++++ ...OUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md | 12 +- docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 16 +- scripts/check-spacetime-schema-guard.mjs | 3 - .../crates/api-server/src/modules/admin.rs | 14 +- .../crates/api-server/src/runtime_profile.rs | 171 ++++++- .../crates/module-runtime/src/application.rs | 29 +- .../crates/module-runtime/src/commands.rs | 77 ++- server-rs/crates/module-runtime/src/domain.rs | 84 ++++ server-rs/crates/module-runtime/src/errors.rs | 14 + server-rs/crates/module-runtime/src/lib.rs | 39 +- .../crates/shared-contracts/src/runtime.rs | 54 +++ server-rs/crates/spacetime-client/src/lib.rs | 5 +- .../crates/spacetime-client/src/mapper.rs | 122 +++++ ...ist_profile_recharge_products_procedure.rs | 61 +++ ...sert_profile_recharge_product_procedure.rs | 62 +++ .../src/module_bindings/mod.rs | 44 ++ .../profile_recharge_product_config_table.rs | 171 +++++++ .../profile_recharge_product_config_type.rs | 101 ++++ ..._recharge_product_admin_list_input_type.rs | 15 + ...roduct_admin_list_procedure_result_type.rs | 19 + ...rge_product_admin_procedure_result_type.rs | 19 + ...echarge_product_admin_upsert_input_type.rs | 31 ++ ...e_recharge_product_config_snapshot_type.rs | 33 ++ .../crates/spacetime-client/src/runtime.rs | 72 +++ .../crates/spacetime-module/src/migration.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 335 ++++++++++++- .../RpgEntryHomeView.recharge.test.tsx | 6 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 9 +- 36 files changed, 2096 insertions(+), 72 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminRechargeProductPage.tsx create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index dde079e5..b1157593 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-15 充值商品配置入库且首充按档位独立计算 + +- 背景:泥点充值原来依赖代码中的固定商品目录,首充双倍也按账号是否买过任一泥点统一隐藏,导致用户买过 `points_60` 后其它未购买档位也失去首充展示。 +- 决策:新增 `profile_recharge_product_config` 作为泥点和会员商品配置真相源,默认商品只在空库时播种;后台通过“充值商品”页维护配置。泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断,`hasPointsRecharged` 仅保留为账号是否发生过任一泥点充值的兼容字段,不再驱动所有商品展示或结算。 +- 影响范围:`module-runtime` 充值领域输入、`spacetime-module` 充值表与 procedure、`spacetime-client` bindings/facade、`api-server` `/admin/api/profile/recharge-products`、`apps/admin-web` 充值商品页、主站充值弹窗。 +- 验证方式:执行 `cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`。 +- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + ## 2026-05-14 创作页图像输入统一封装为图像组件 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 44c7fc30..f0489c99 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -286,6 +286,14 @@ - 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 - 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 +## SpacetimeDB schema guard 新增表应校验 sidecar,不应把新增本身当失败 + +- 现象:新增 SpacetimeDB 表后,schema guard 只要看到新 table accessor 就直接报错,哪怕 `migration.rs`、表目录和生成绑定都已同步。 +- 原因:守卫脚本把“发现新增表”本身当成失败,而不是把它当成需要校验 sidecar 的信号。 +- 处理:新增表只应触发 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和 bindings 的一致性检查;当 sidecar 都已同步时,新增表应允许通过。不要把“合法新增表”本身判成失败。 +- 验证:`npm run check:spacetime-schema` 在新增表、迁移、目录和绑定都同步后应通过。 +- 关联:`scripts/check-spacetime-schema-guard.mjs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + ## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature - 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform`。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 4ddb3658..b1e618cd 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -14,6 +14,7 @@ import type { AdminTrackingEventListQuery, AdminTrackingEventListResponse, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, ApiErrorEnvelope, @@ -21,6 +22,8 @@ import type { ApiSuccessEnvelope, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileRechargeProductConfigAdminListResponse, + ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, ProfileTaskConfigAdminListResponse, @@ -279,6 +282,27 @@ export function disableProfileTaskConfig( ); } +export function listProfileRechargeProducts(token: string) { + return request( + '/admin/api/profile/recharge-products', + {token}, + ); +} + +export function upsertProfileRechargeProduct( + token: string, + payload: AdminUpsertProfileRechargeProductRequest, +) { + return request( + '/admin/api/profile/recharge-products', + { + method: 'POST', + token, + body: payload, + }, + ); +} + function normalizeBaseUrl(value: string) { return value.trim().replace(/\/+$/, ''); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 013d36f4..43bc2e03 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -132,6 +132,8 @@ export interface AdminDebugHttpResponse { export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; +export type ProfileRechargeProductKind = 'points' | 'membership'; +export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year'; export interface AdminTrackingEventListQuery { eventKey?: string; @@ -207,6 +209,21 @@ export interface AdminDisableProfileTaskConfigRequest { taskId: string; } +export interface AdminUpsertProfileRechargeProductRequest { + productId: string; + title: string; + priceCents: number; + kind: ProfileRechargeProductKind; + pointsAmount: number; + bonusPoints: number; + durationDays: number; + badgeLabel?: string | null; + description?: string | null; + tier: ProfileMembershipTier; + enabled: boolean; + sortOrder: number; +} + export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; @@ -260,6 +277,29 @@ export interface ProfileTaskConfigAdminListResponse { entries: ProfileTaskConfigAdminResponse[]; } +export interface ProfileRechargeProductConfigAdminResponse { + productId: string; + title: string; + priceCents: number; + kind: ProfileRechargeProductKind; + pointsAmount: number; + bonusPoints: number; + durationDays: number; + badgeLabel: string; + description: string; + tier: ProfileMembershipTier; + enabled: boolean; + sortOrder: number; + createdBy: string; + createdAt: string; + updatedBy: string; + updatedAt: string; +} + +export interface ProfileRechargeProductConfigAdminListResponse { + entries: ProfileRechargeProductConfigAdminResponse[]; +} + export interface AdminTrackingEventEntryPayload { eventId: string; eventKey: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 5658fb60..f8b1e792 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -9,6 +9,7 @@ import { import type { AdminSessionPayload, ProfileInviteCodeAdminResponse, + ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminResponse, ProfileTaskConfigAdminResponse, } from '../api/adminApiTypes'; @@ -23,6 +24,7 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; +import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; @@ -47,6 +49,8 @@ export function AdminApp() { useState(null); const [taskConfigResult, setTaskConfigResult] = useState(null); + const [rechargeProductResult, setRechargeProductResult] = + useState(null); const clearSession = useCallback((message = '') => { clearStoredAdminToken(); @@ -55,6 +59,7 @@ export function AdminApp() { setRedeemResult(null); setInviteResult(null); setTaskConfigResult(null); + setRechargeProductResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -124,6 +129,7 @@ export function AdminApp() { setRedeemResult(null); setInviteResult(null); setTaskConfigResult(null); + setRechargeProductResult(null); setLoginNotice(''); setStatus('authenticated'); }, []); @@ -207,6 +213,14 @@ export function AdminApp() { onResultChange={setTaskConfigResult} /> ) : null} + {routeId === 'recharge-products' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 0dc05d5b..9f2f7f3f 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -1,5 +1,6 @@ import { Bug, + BadgeDollarSign, LayoutDashboard, LogOut, ShieldCheck, @@ -32,6 +33,7 @@ const routeIcons = { redeem: TicketPercent, invite: TicketCheck, tasks: ListChecks, + 'recharge-products': BadgeDollarSign, 'creation-entry': SlidersHorizontal, } satisfies Record; diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index f5257934..99831be5 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -6,6 +6,7 @@ export type AdminRouteId = | 'redeem' | 'invite' | 'tasks' + | 'recharge-products' | 'creation-entry'; export interface AdminRouteDefinition { @@ -22,6 +23,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, + {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, ]; diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx new file mode 100644 index 00000000..cfa3637c --- /dev/null +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -0,0 +1,451 @@ +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; + +import { + listProfileRechargeProducts, + upsertProfileRechargeProduct, +} from '../api/adminApiClient'; +import type { + ProfileMembershipTier, + ProfileRechargeProductConfigAdminResponse, + ProfileRechargeProductKind, +} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; +import {handlePageError} from './pageUtils'; + +interface AdminRechargeProductPageProps { + token: string; + result: ProfileRechargeProductConfigAdminResponse | null; + onUnauthorized: (message?: string) => void; + onResultChange: (result: ProfileRechargeProductConfigAdminResponse) => void; +} + +const productKinds: Array<{value: ProfileRechargeProductKind; label: string}> = [ + {value: 'points', label: '泥点'}, + {value: 'membership', label: '会员'}, +]; + +const membershipTiers: Array<{value: ProfileMembershipTier; label: string}> = [ + {value: 'month', label: '月卡'}, + {value: 'season', label: '季卡'}, + {value: 'year', label: '年卡'}, +]; + +export function AdminRechargeProductPage({ + token, + result, + onUnauthorized, + onResultChange, +}: AdminRechargeProductPageProps) { + const [entries, setEntries] = useState< + ProfileRechargeProductConfigAdminResponse[] + >([]); + const [productId, setProductId] = useState('points_60'); + const [title, setTitle] = useState('60泥点'); + const [priceCents, setPriceCents] = useState('600'); + const [kind, setKind] = useState('points'); + const [pointsAmount, setPointsAmount] = useState('60'); + const [bonusPoints, setBonusPoints] = useState('60'); + const [durationDays, setDurationDays] = useState('0'); + const [badgeLabel, setBadgeLabel] = useState('首充双倍'); + const [description, setDescription] = useState('首充送60泥点'); + const [tier, setTier] = useState('normal'); + const [enabled, setEnabled] = useState(true); + const [sortOrder, setSortOrder] = useState('0'); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); + + useEffect(() => { + void refreshProducts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshProducts() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileRechargeProducts(token); + const sortedEntries = sortProducts(response.entries); + setEntries(sortedEntries); + const firstEntry = sortedEntries[0]; + if (firstEntry) { + fillForm(firstEntry); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + if (isSaving) { + return; + } + + setErrorMessage(''); + const confirmed = await confirmWrite({ + action: enabled ? '保存充值商品' : '停用充值商品', + target: productId.trim(), + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertProfileRechargeProduct(token, { + productId: productId.trim(), + title: title.trim(), + priceCents: parsePositiveInteger(priceCents), + kind, + pointsAmount: kind === 'points' ? parsePositiveInteger(pointsAmount) : 0, + bonusPoints: kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0, + durationDays: + kind === 'membership' ? parsePositiveInteger(durationDays) : 0, + badgeLabel: kind === 'points' ? badgeLabel.trim() : '', + description: description.trim(), + tier: kind === 'membership' ? tier : 'normal', + enabled, + sortOrder: parseInteger(sortOrder), + }); + onResultChange(response); + upsertEntry(response); + fillForm(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function upsertEntry(next: ProfileRechargeProductConfigAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.productId !== next.productId); + return sortProducts([...rest, next]); + }); + } + + function fillForm(entry: ProfileRechargeProductConfigAdminResponse) { + setProductId(entry.productId); + setTitle(entry.title); + setPriceCents(String(entry.priceCents)); + setKind(entry.kind); + setPointsAmount(String(entry.pointsAmount)); + setBonusPoints(String(entry.bonusPoints)); + setDurationDays(String(entry.durationDays)); + setBadgeLabel(entry.badgeLabel); + setDescription(entry.description); + setTier(entry.tier); + setEnabled(entry.enabled); + setSortOrder(String(entry.sortOrder)); + } + + return ( +
+
+
+

充值商品

+

泥点与会员档位

+
+ +
+ + {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} + +
+
+
+ + +
+ +
+ {productKinds.map((item) => ( + + ))} +
+ +
+ + +
+ + {kind === 'points' ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + +
+ + +
+ + + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + +
+ +
+
+
+

商品列表

+ {entries.length} +
+
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
商品类型价格内容状态
+ + {entry.productId} + {formatProductKind(entry.kind)}{formatPrice(entry.priceCents)}{formatProductContent(entry)}{entry.enabled ? '启用' : '停用'}
+
+
+ + {result ? ( +
+
+

最近保存

+ {result.updatedAt} +
+
+
+
商品
+
{result.productId}
+
+
+
状态
+
{result.enabled ? '启用' : '停用'}
+
+
+
+ ) : null} +
+
+ + {confirmDialog} +
+ ); +} + +function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) { + return [...entries].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.productId.localeCompare(right.productId); + }); +} + +function formatProductKind(kind: ProfileRechargeProductKind) { + return kind === 'points' ? '泥点' : '会员'; +} + +function formatTier(tier: ProfileMembershipTier) { + if (tier === 'month') { + return '月卡'; + } + if (tier === 'season') { + return '季卡'; + } + if (tier === 'year') { + return '年卡'; + } + return '普通'; +} + +function formatProductContent(entry: ProfileRechargeProductConfigAdminResponse) { + if (entry.kind === 'points') { + return `${entry.pointsAmount}+${entry.bonusPoints}`; + } + return `${formatTier(entry.tier)} ${entry.durationDays}天`; +} + +function formatPrice(priceCents: number) { + return `¥${(priceCents / 100).toFixed(2)}`; +} + +function parsePositiveInteger(value: string) { + const parsed = parseInteger(value); + return parsed > 0 ? parsed : 0; +} + +function parseNonNegativeInteger(value: string) { + const parsed = parseInteger(value); + return parsed > 0 ? parsed : 0; +} + +function parseInteger(value: string) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return 0; + } + return parsed; +} diff --git a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md index 693b0369..80beffa5 100644 --- a/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md +++ b/docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md @@ -24,9 +24,13 @@ | `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280泥点 | | `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280泥点 | -泥点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账泥点为基础泥点与等额赠送泥点之和;已有充值流水后只到账基础泥点。实际到账泥点写入交易流水,余额以 SpacetimeDB projection 为准。 +泥点充值默认初始化 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。默认档位只作为空库种子写入 `profile_recharge_product_config`,运行时充值中心展示、下单校验和支付确认结算都以 SpacetimeDB 配置表为准,不再把代码中的商品目录作为业务真相源。 -充值中心返回的 `hasPointsRecharged` 是首充资格的展示与结算共同依据:当它为 `true` 时,后端下发的泥点套餐应只保留基础泥点、清空首充徽标与赠送文案;前端即使收到旧版本快照中残留的 `bonusPoints` / `badgeLabel`,也必须按 `hasPointsRecharged` 隐藏“首充双倍”和 `基础+赠送` 展示。这样可以避免第二次充值只到账基础泥点时,弹窗仍显示 `60+60` 等已失效权益。 +全部泥点档位参与档位首充双倍:首充资格按 `productId` 独立判断。用户购买过 `points_60` 后,再次购买 `points_60` 只到账基础泥点;但 `points_180`、`points_300` 等未购买过的档位仍保留各自的首充赠送。实际到账泥点写入 `profile_recharge_order.points_delta` 与钱包流水,余额以 SpacetimeDB projection 为准。 + +充值中心返回的 `pointProducts` 已由后端按当前账号和每个 `productId` 计算有效展示状态:已完成首充的档位清空 `bonusPoints`、`badgeLabel` 与首充说明,未完成首充的档位继续显示 `首充双倍` 和 `基础+赠送`。`hasPointsRecharged` 仅保留为兼容字段,表示账号是否发生过任一泥点充值,不再作为隐藏所有档位首充或计算结算金额的依据。前端不得再用 `hasPointsRecharged` 对所有泥点商品做统一屏蔽。 + +后台通过“充值商品”页维护 `profile_recharge_product_config`,字段包括 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、启用状态和排序。保存后新的充值中心快照、下单与支付确认立即读取配置表;历史订单继续保留下单当时写入的商品标题、金额和状态。 ### 2.2 会员卡套餐 @@ -157,7 +161,7 @@ 1. 普通用户打开弹窗能看到泥点与会员套餐。 2. 泥点购买后余额增加,流水来源为 `points_recharge`。 -3. 首充赠送只在首次泥点充值时生效。 -4. 已产生 `points_recharge` 流水后,再打开充值弹窗不应展示“首充双倍”徽标或 `60+60` 等赠送泥点组合。 +3. 首充赠送按泥点档位独立生效。 +4. 某个 `productId` 已成功完成泥点充值后,再打开充值弹窗时仅该档位不再展示“首充双倍”徽标或 `60+60` 等赠送泥点组合,其他未购买过的泥点档位仍展示各自首充权益。 5. 会员购买后会员状态与到期时间立即更新。 6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index afea765f..b6bc01ac 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -24,7 +24,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | --- | --- | | 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` | | 认证 | `auth_store_snapshot`, `auth_store_projection_meta`, `user_account`, `auth_identity`, `refresh_session` | -| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_feedback_submission`, `profile_save_archive` | +| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_product_config`, `profile_recharge_order`, `profile_feedback_submission`, `profile_save_archive` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` | @@ -336,11 +336,23 @@ SELECT * FROM public_work_like WHERE user_id = ''; SELECT * FROM profile_membership WHERE user_id = ''; ``` +### `profile_recharge_product_config` + +- 作用:充值商品配置表,是泥点和会员充值商品的运行时真相源;默认商品只在空库时作为种子写入,后台“充值商品”页后续维护该表。 +- 结构:`product_id PK: String`, `title: String`, `price_cents: u64`, `kind: RuntimeProfileRechargeProductKind`, `points_amount: u64`, `bonus_points: u64`, `duration_days: u32`, `badge_label: String`, `description: String`, `tier: RuntimeProfileMembershipTier`, `enabled: bool`, `sort_order: i32`, `created_by: String`, `created_at: Timestamp`, `updated_by: String`, `updated_at: Timestamp`。 +- 索引:主键 `product_id`。 +- 充值口径:下单只允许读取 `enabled = true` 的商品;支付确认允许按订单 `product_id` 读取已存在商品完成历史 pending 订单。泥点首充按 `user_id + product_id` 独立判断,只清空已购买档位的 `bonus_points`、`badge_label` 和首充说明。 + +```sql +SELECT * FROM profile_recharge_product_config ORDER BY sort_order; +SELECT * FROM profile_recharge_product_config WHERE product_id = ''; +``` + ### `profile_recharge_order` - 作用:充值订单表,记录用户购买泥点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。 - 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option`, `provider_transaction_id: Option`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option`。 -- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid`,`provider_transaction_id` 保存微信支付平台订单号。 +- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid`,`provider_transaction_id` 保存微信支付平台订单号。订单商品信息来自 `profile_recharge_product_config`,泥点首充赠送按同一用户的同一 `product_id` 历史 `paid` 订单独立计算。 - 索引:`user_id`, `(user_id, created_at)`。 ```sql diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 301ac967..1daf12b9 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -572,9 +572,6 @@ function compareTables(baseTables, currentTables) { for (const [accessor, table] of currentTables) { if (!baseTables.has(accessor)) { schemaChanged = true; - failures.push( - `${table.path}:${table.line}: 新增 SpacetimeDB 表 ${accessor}。请同步 migration.rs、表目录和生成绑定。`, - ); } } diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index a3afe62d..6c5242bd 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -8,8 +8,9 @@ use crate::{ }, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, - admin_list_profile_invite_codes, admin_list_profile_redeem_codes, - admin_list_profile_task_configs, admin_upsert_profile_invite_code, + admin_list_profile_invite_codes, admin_list_profile_recharge_products, + admin_list_profile_redeem_codes, admin_list_profile_task_configs, + admin_upsert_profile_invite_code, admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, }, state::AppState, @@ -104,7 +105,14 @@ pub fn router(state: AppState) -> Router { ) .route( "/admin/api/profile/tasks/disable", - axum::routing::post(admin_disable_profile_task_config) + axum::routing::post(admin_disable_profile_task_config).route_layer( + middleware::from_fn_with_state(state.clone(), require_admin_auth), + ), + ) + .route( + "/admin/api/profile/recharge-products", + get(admin_list_profile_recharge_products) + .post(admin_upsert_profile_recharge_product) .route_layer(middleware::from_fn_with_state(state, require_admin_auth)), ) } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 987a8cf1..10f01043 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -9,13 +9,15 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord, - RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, - RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, - RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, - RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, - RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, + RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, + RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, + RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, + RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, + RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, + RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, + RuntimeTrackingScopeKind, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -23,12 +25,16 @@ use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, - AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, - AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, + AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, + AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, + AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, - CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, - PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, - PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, + PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, PROFILE_MEMBERSHIP_TIER_SEASON, + PROFILE_MEMBERSHIP_TIER_YEAR, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, + PROFILE_RECHARGE_PRODUCT_KIND_POINTS, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, + PROFILE_TASK_STATUS_CLAIMED, 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_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, @@ -42,6 +48,7 @@ use shared_contracts::runtime::{ ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, + ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse, @@ -669,6 +676,84 @@ pub async fn admin_disable_profile_task_config( )) } +pub async fn admin_list_profile_recharge_products( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Result, Response> { + let entries = state + .spacetime_client() + .admin_list_profile_recharge_products(admin.session().subject.clone()) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileRechargeProductConfigAdminListResponse { + entries: entries + .into_iter() + .map(build_profile_recharge_product_config_admin_response) + .collect(), + }, + )) +} + +pub async fn admin_upsert_profile_recharge_product( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let kind = parse_profile_recharge_product_kind(&payload.kind).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_upsert_profile_recharge_product( + admin.session().subject.clone(), + payload.product_id, + payload.title, + payload.price_cents, + kind, + payload.points_amount, + payload.bonus_points, + payload.duration_days, + payload.badge_label.unwrap_or_default(), + payload.description.unwrap_or_default(), + tier, + payload.enabled, + payload.sort_order.unwrap_or(10), + updated_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_profile_recharge_product_config_admin_response(record), + )) +} + pub async fn admin_list_profile_redeem_codes( State(state): State, Extension(request_context): Extension, @@ -1172,6 +1257,29 @@ fn build_profile_task_config_admin_response( } } +fn build_profile_recharge_product_config_admin_response( + record: RuntimeProfileRechargeProductConfigRecord, +) -> ProfileRechargeProductConfigAdminResponse { + ProfileRechargeProductConfigAdminResponse { + product_id: record.product_id, + title: record.title, + price_cents: record.price_cents, + kind: format_profile_recharge_product_kind(record.kind).to_string(), + points_amount: record.points_amount, + bonus_points: record.bonus_points, + duration_days: record.duration_days, + badge_label: record.badge_label, + description: record.description, + tier: format_profile_membership_tier(record.tier).to_string(), + enabled: record.enabled, + sort_order: record.sort_order, + created_by: record.created_by, + created_at: record.created_at, + updated_by: record.updated_by, + updated_at: record.updated_at, + } +} + fn normalize_admin_invite_code_metadata(metadata: Option) -> Result { let metadata = match metadata { Some(Value::Null) | None => json!({}), @@ -1233,6 +1341,28 @@ fn parse_profile_task_cycle(raw: &str) -> Result Result { + match raw.trim().to_ascii_lowercase().as_str() { + PROFILE_RECHARGE_PRODUCT_KIND_POINTS => Ok(RuntimeProfileRechargeProductKind::Points), + PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP => { + Ok(RuntimeProfileRechargeProductKind::Membership) + } + _ => Err("充值商品类型无效".to_string()), + } +} + +fn parse_profile_membership_tier(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal), + PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month), + PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season), + PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year), + _ => Err("会员档位无效".to_string()), + } +} + fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), @@ -1269,6 +1399,22 @@ fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str } } +fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) -> &'static str { + match kind { + RuntimeProfileRechargeProductKind::Points => PROFILE_RECHARGE_PRODUCT_KIND_POINTS, + RuntimeProfileRechargeProductKind::Membership => PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP, + } +} + +fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str { + match tier { + RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL, + RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH, + RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON, + RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR, + } +} + fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str { match scope_kind { RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE, @@ -1702,6 +1848,7 @@ mod tests { for uri in [ "/admin/api/profile/redeem-codes", "/admin/api/profile/invite-codes", + "/admin/api/profile/recharge-products", ] { let response = app .clone() diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index a4225fa7..bc894dda 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -292,6 +292,31 @@ pub fn build_runtime_profile_recharge_product_record( } } +pub fn build_runtime_profile_recharge_product_config_record( + snapshot: RuntimeProfileRechargeProductConfigSnapshot, +) -> RuntimeProfileRechargeProductConfigRecord { + RuntimeProfileRechargeProductConfigRecord { + product_id: snapshot.product_id, + title: snapshot.title, + price_cents: snapshot.price_cents, + kind: snapshot.kind, + points_amount: snapshot.points_amount, + bonus_points: snapshot.bonus_points, + duration_days: snapshot.duration_days, + badge_label: snapshot.badge_label, + description: snapshot.description, + tier: snapshot.tier, + enabled: snapshot.enabled, + sort_order: snapshot.sort_order, + created_by: snapshot.created_by, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_by: snapshot.updated_by, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + pub fn build_runtime_profile_membership_benefit_record( snapshot: RuntimeProfileMembershipBenefitSnapshot, ) -> RuntimeProfileMembershipBenefitRecord { @@ -1114,9 +1139,9 @@ fn hash_runtime_profile_recharge_order_key( pub fn resolve_runtime_profile_points_recharge_delta( product: &RuntimeProfileRechargeProductSnapshot, - has_points_recharged: bool, + has_product_recharged: bool, ) -> u64 { - let bonus_points = if has_points_recharged { + let bonus_points = if has_product_recharged { 0 } else { product.bonus_points diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index e3249e64..8504f38c 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -11,7 +11,7 @@ use shared_kernel::{ use crate::domain::*; use crate::errors::*; -use crate::{format_utc_micros, runtime_profile_recharge_product_by_id}; +use crate::format_utc_micros; pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8; pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16; @@ -259,9 +259,6 @@ pub fn build_runtime_profile_recharge_order_create_input( let user_id = normalize_runtime_profile_user_id(user_id)?; let product_id = normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?; - if runtime_profile_recharge_product_by_id(&product_id).is_none() { - return Err(RuntimeProfileFieldError::UnknownRechargeProduct); - } let payment_channel = normalize_required_string(payment_channel) .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); @@ -273,6 +270,78 @@ pub fn build_runtime_profile_recharge_order_create_input( }) } +pub fn build_runtime_profile_recharge_product_admin_list_input( + admin_user_id: String, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + Ok(RuntimeProfileRechargeProductAdminListInput { admin_user_id }) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_runtime_profile_recharge_product_admin_upsert_input( + admin_user_id: String, + product_id: String, + title: String, + price_cents: u64, + kind: RuntimeProfileRechargeProductKind, + points_amount: u64, + bonus_points: u64, + duration_days: u32, + badge_label: String, + description: String, + tier: RuntimeProfileMembershipTier, + enabled: bool, + sort_order: i32, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let product_id = + normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?; + let title = + normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?; + if price_cents == 0 { + return Err(RuntimeProfileFieldError::InvalidRechargeProductPrice); + } + match kind { + RuntimeProfileRechargeProductKind::Points => { + if points_amount == 0 { + return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); + } + if duration_days != 0 || tier != RuntimeProfileMembershipTier::Normal { + return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + } + } + RuntimeProfileRechargeProductKind::Membership => { + if duration_days == 0 { + return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration); + } + if points_amount != 0 + || bonus_points != 0 + || tier == RuntimeProfileMembershipTier::Normal + { + return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); + } + } + } + + Ok(RuntimeProfileRechargeProductAdminUpsertInput { + admin_user_id, + product_id, + title, + price_cents, + kind, + points_amount, + bonus_points, + duration_days, + badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(), + description: normalize_optional_string(Some(description)).unwrap_or_default(), + tier, + enabled, + sort_order, + updated_at_micros, + }) +} + pub fn build_runtime_profile_recharge_order_paid_input( order_id: String, paid_at_micros: i64, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index e327f28d..a820ffda 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -986,6 +986,27 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub tier: RuntimeProfileMembershipTier, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeProductConfigSnapshot { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub created_by: String, + pub created_at_micros: i64, + pub updated_by: String, + pub updated_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipBenefitSnapshot { @@ -1054,6 +1075,47 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeProductAdminListInput { + pub admin_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeProductAdminUpsertInput { + pub admin_user_id: String, + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeProductAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeProductAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { @@ -1463,6 +1525,28 @@ pub struct RuntimeProfileRechargeProductRecord { pub tier: RuntimeProfileMembershipTier, } +#[derive(Clone, Debug, PartialEq)] +pub struct RuntimeProfileRechargeProductConfigRecord { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub created_by: String, + pub created_at: String, + pub created_at_micros: i64, + pub updated_by: String, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipBenefitRecord { pub benefit_name: String, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 59efc6d0..437c6204 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -74,6 +74,12 @@ pub enum RuntimeProfileFieldError { TaskAlreadyClaimed, MissingOrderId, MissingProductId, + MissingProductTitle, + InvalidRechargeProductPrice, + InvalidRechargeProductPoints, + InvalidRechargeProductDuration, + InvalidRechargeProductKind, + InvalidRechargeProductTier, MissingWorldKey, MissingBottomTab, MissingCheckpointSessionId, @@ -136,6 +142,14 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), + Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"), + Self::InvalidRechargeProductPrice => f.write_str("recharge.price_cents 必须大于 0"), + Self::InvalidRechargeProductPoints => f.write_str("泥点商品 points_amount 必须大于 0"), + Self::InvalidRechargeProductDuration => { + f.write_str("会员商品 duration_days 必须大于 0") + } + Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"), + Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ff9c21b0..15e0e43c 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -77,19 +77,11 @@ pub fn runtime_profile_recharge_point_products() -> Vec Vec { - let mut products = runtime_profile_recharge_point_products(); - if has_points_recharged { - for product in &mut products { - product.bonus_points = 0; - product.badge_label.clear(); - product.description = product.title.clone(); - } - } - products + runtime_profile_recharge_point_products() } pub fn runtime_profile_recharge_membership_products() -> Vec @@ -722,32 +714,33 @@ mod tests { } #[test] - fn recharge_point_products_resolve_effective_first_bonus_display() { + 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泥点"); let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); - assert_eq!(repeated_recharge_products[0].bonus_points, 0); - assert_eq!(repeated_recharge_products[0].badge_label, ""); - assert_eq!(repeated_recharge_products[0].description, "60泥点"); - assert_eq!(repeated_recharge_products[5].bonus_points, 0); - assert_eq!(repeated_recharge_products[5].badge_label, ""); - assert_eq!(repeated_recharge_products[5].description, "3280泥点"); + 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泥点"); } #[test] - fn build_recharge_order_input_rejects_unknown_product() { - let error = build_runtime_profile_recharge_order_create_input( + fn build_recharge_order_input_accepts_configured_product_id_later() { + let input = build_runtime_profile_recharge_order_create_input( "user-1".to_string(), - "bad-product".to_string(), + "custom-points-600".to_string(), "mock".to_string(), 1, ) - .expect_err("unknown product should fail"); + .expect("product existence is validated against database config later"); - assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct); + assert_eq!(input.product_id, "custom-points-600"); + assert_eq!(input.payment_channel, "mock"); } #[test] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index e36ac817..893f70ac 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -21,6 +21,12 @@ pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete"; pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable"; pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed"; pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled"; +pub const PROFILE_RECHARGE_PRODUCT_KIND_POINTS: &str = "points"; +pub const PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP: &str = "membership"; +pub const PROFILE_MEMBERSHIP_TIER_NORMAL: &str = "normal"; +pub const PROFILE_MEMBERSHIP_TIER_MONTH: &str = "month"; +pub const PROFILE_MEMBERSHIP_TIER_SEASON: &str = "season"; +pub const PROFILE_MEMBERSHIP_TIER_YEAR: &str = "year"; pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open"; pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; @@ -436,6 +442,33 @@ pub struct ProfileTaskConfigAdminListResponse { pub entries: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRechargeProductConfigAdminResponse { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: String, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: String, + pub enabled: bool, + pub sort_order: i32, + pub created_by: String, + pub created_at: String, + pub updated_by: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileRechargeProductConfigAdminListResponse { + pub entries: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AnalyticsMetricQueryRequest { @@ -478,6 +511,27 @@ pub struct AdminUpsertProfileTaskConfigRequest { pub sort_order: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileRechargeProductRequest { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: String, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + #[serde(default)] + pub badge_label: Option, + #[serde(default)] + pub description: Option, + pub tier: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub sort_order: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminDisableProfileTaskConfigRequest { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 7a6e2bd4..95a674f4 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -165,7 +165,7 @@ use module_runtime::{ RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, @@ -185,6 +185,9 @@ use module_runtime::{ build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_get_input, + build_runtime_profile_recharge_product_admin_list_input, + build_runtime_profile_recharge_product_admin_upsert_input, + build_runtime_profile_recharge_product_config_record, build_runtime_profile_redeem_code_admin_disable_input, build_runtime_profile_redeem_code_admin_list_input, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index dd19164e..225b632c 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -309,6 +309,39 @@ impl From } } +impl From + for RuntimeProfileRechargeProductAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeProductAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileRechargeProductAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeProductAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + product_id: input.product_id, + title: input.title, + price_cents: input.price_cents, + kind: map_runtime_profile_recharge_product_kind(input.kind), + points_amount: input.points_amount, + bonus_points: input.bonus_points, + duration_days: input.duration_days, + badge_label: input.badge_label, + description: input.description, + tier: map_runtime_profile_membership_tier(input.tier), + enabled: input.enabled, + sort_order: input.sort_order, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for RuntimeProfileRedeemCodeAdminUpsertInput { @@ -1157,6 +1190,40 @@ pub(crate) fn map_runtime_profile_task_config_admin_procedure_result( )) } +pub(crate) fn map_runtime_profile_recharge_product_admin_list_procedure_result( + result: RuntimeProfileRechargeProductAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_recharge_product_config_record( + map_runtime_profile_recharge_product_config_snapshot(snapshot), + ) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_recharge_product_admin_procedure_result( + result: RuntimeProfileRechargeProductAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("recharge product config 快照"))?; + + Ok(build_runtime_profile_recharge_product_config_record( + map_runtime_profile_recharge_product_config_snapshot(snapshot), + )) +} + pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( result: RuntimeProfileRedeemCodeAdminProcedureResult, ) -> Result { @@ -2237,6 +2304,29 @@ pub(crate) fn map_runtime_profile_recharge_product_snapshot( } } +pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( + snapshot: RuntimeProfileRechargeProductConfigSnapshot, +) -> module_runtime::RuntimeProfileRechargeProductConfigSnapshot { + module_runtime::RuntimeProfileRechargeProductConfigSnapshot { + product_id: snapshot.product_id, + title: snapshot.title, + price_cents: snapshot.price_cents, + kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), + points_amount: snapshot.points_amount, + bonus_points: snapshot.bonus_points, + duration_days: snapshot.duration_days, + badge_label: snapshot.badge_label, + description: snapshot.description, + tier: map_runtime_profile_membership_tier_back(snapshot.tier), + enabled: snapshot.enabled, + sort_order: snapshot.sort_order, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_by: snapshot.updated_by, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_membership_benefit_snapshot( snapshot: RuntimeProfileMembershipBenefitSnapshot, ) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { @@ -5037,6 +5127,19 @@ pub(crate) fn map_runtime_profile_redeem_code_mode_back( } } +pub(crate) fn map_runtime_profile_recharge_product_kind( + value: module_runtime::RuntimeProfileRechargeProductKind, +) -> crate::module_bindings::RuntimeProfileRechargeProductKind { + match value { + module_runtime::RuntimeProfileRechargeProductKind::Points => { + crate::module_bindings::RuntimeProfileRechargeProductKind::Points + } + module_runtime::RuntimeProfileRechargeProductKind::Membership => { + crate::module_bindings::RuntimeProfileRechargeProductKind::Membership + } + } +} + pub(crate) fn map_runtime_profile_recharge_product_kind_back( value: crate::module_bindings::RuntimeProfileRechargeProductKind, ) -> module_runtime::RuntimeProfileRechargeProductKind { @@ -5050,6 +5153,25 @@ pub(crate) fn map_runtime_profile_recharge_product_kind_back( } } +pub(crate) fn map_runtime_profile_membership_tier( + value: module_runtime::RuntimeProfileMembershipTier, +) -> crate::module_bindings::RuntimeProfileMembershipTier { + match value { + module_runtime::RuntimeProfileMembershipTier::Normal => { + crate::module_bindings::RuntimeProfileMembershipTier::Normal + } + module_runtime::RuntimeProfileMembershipTier::Month => { + crate::module_bindings::RuntimeProfileMembershipTier::Month + } + module_runtime::RuntimeProfileMembershipTier::Season => { + crate::module_bindings::RuntimeProfileMembershipTier::Season + } + module_runtime::RuntimeProfileMembershipTier::Year => { + crate::module_bindings::RuntimeProfileMembershipTier::Year + } + } +} + pub(crate) fn map_runtime_profile_membership_status_back( value: crate::module_bindings::RuntimeProfileMembershipStatus, ) -> module_runtime::RuntimeProfileMembershipStatus { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.rs new file mode 100644 index 00000000..a1deed88 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_products_procedure.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}; + +use super::runtime_profile_recharge_product_admin_list_input_type::RuntimeProfileRechargeProductAdminListInput; +use super::runtime_profile_recharge_product_admin_list_procedure_result_type::RuntimeProfileRechargeProductAdminListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminListProfileRechargeProductsArgs { + pub input: RuntimeProfileRechargeProductAdminListInput, +} + +impl __sdk::InModule for AdminListProfileRechargeProductsArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_list_profile_recharge_products`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_list_profile_recharge_products { + fn admin_list_profile_recharge_products( + &self, + input: RuntimeProfileRechargeProductAdminListInput, + ) { + self.admin_list_profile_recharge_products_then(input, |_, _| {}); + } + + fn admin_list_profile_recharge_products_then( + &self, + input: RuntimeProfileRechargeProductAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_list_profile_recharge_products for super::RemoteProcedures { + fn admin_list_profile_recharge_products_then( + &self, + input: RuntimeProfileRechargeProductAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>( + "admin_list_profile_recharge_products", + AdminListProfileRechargeProductsArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs new file mode 100644 index 00000000..83941b83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_recharge_product_procedure.rs @@ -0,0 +1,62 @@ +// 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}; + +use super::runtime_profile_recharge_product_admin_procedure_result_type::RuntimeProfileRechargeProductAdminProcedureResult; +use super::runtime_profile_recharge_product_admin_upsert_input_type::RuntimeProfileRechargeProductAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileRechargeProductArgs { + pub input: RuntimeProfileRechargeProductAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileRechargeProductArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_recharge_product`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_upsert_profile_recharge_product { + fn admin_upsert_profile_recharge_product( + &self, + input: RuntimeProfileRechargeProductAdminUpsertInput, + ) { + self.admin_upsert_profile_recharge_product_then(input, |_, _| {}); + } + + fn admin_upsert_profile_recharge_product_then( + &self, + input: RuntimeProfileRechargeProductAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_recharge_product for super::RemoteProcedures { + fn admin_upsert_profile_recharge_product_then( + &self, + input: RuntimeProfileRechargeProductAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>( + "admin_upsert_profile_recharge_product", + AdminUpsertProfileRechargeProductArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 5fa98210..379a2436 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -11,9 +11,11 @@ pub mod acknowledge_quest_completion_reducer; pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_task_config_procedure; pub mod admin_list_profile_invite_codes_procedure; +pub mod admin_list_profile_recharge_products_procedure; pub mod admin_list_profile_redeem_codes_procedure; pub mod admin_list_profile_task_configs_procedure; pub mod admin_upsert_profile_invite_code_procedure; +pub mod admin_upsert_profile_recharge_product_procedure; pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_task_config_procedure; pub mod advance_puzzle_next_level_procedure; @@ -461,6 +463,8 @@ pub mod profile_played_world_table; pub mod profile_played_world_type; pub mod profile_recharge_order_table; pub mod profile_recharge_order_type; +pub mod profile_recharge_product_config_table; +pub mod profile_recharge_product_config_type; pub mod profile_redeem_code_table; pub mod profile_redeem_code_type; pub mod profile_redeem_code_usage_table; @@ -652,6 +656,11 @@ pub mod runtime_profile_recharge_order_get_input_type; pub mod runtime_profile_recharge_order_paid_input_type; pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_status_type; +pub mod runtime_profile_recharge_product_admin_list_input_type; +pub mod runtime_profile_recharge_product_admin_list_procedure_result_type; +pub mod runtime_profile_recharge_product_admin_procedure_result_type; +pub mod runtime_profile_recharge_product_admin_upsert_input_type; +pub mod runtime_profile_recharge_product_config_snapshot_type; pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_snapshot_type; pub mod runtime_profile_redeem_code_admin_disable_input_type; @@ -857,9 +866,11 @@ pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config; pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes; +pub use admin_list_profile_recharge_products_procedure::admin_list_profile_recharge_products; pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes; pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs; pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; +pub use admin_upsert_profile_recharge_product_procedure::admin_upsert_profile_recharge_product; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; @@ -1307,6 +1318,8 @@ pub use profile_played_world_table::*; pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_recharge_order_table::*; pub use profile_recharge_order_type::ProfileRechargeOrder; +pub use profile_recharge_product_config_table::*; +pub use profile_recharge_product_config_type::ProfileRechargeProductConfig; pub use profile_redeem_code_table::*; pub use profile_redeem_code_type::ProfileRedeemCode; pub use profile_redeem_code_usage_table::*; @@ -1498,6 +1511,11 @@ pub use runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrd pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; +pub use runtime_profile_recharge_product_admin_list_input_type::RuntimeProfileRechargeProductAdminListInput; +pub use runtime_profile_recharge_product_admin_list_procedure_result_type::RuntimeProfileRechargeProductAdminListProcedureResult; +pub use runtime_profile_recharge_product_admin_procedure_result_type::RuntimeProfileRechargeProductAdminProcedureResult; +pub use runtime_profile_recharge_product_admin_upsert_input_type::RuntimeProfileRechargeProductAdminUpsertInput; +pub use runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; @@ -2020,6 +2038,7 @@ pub struct DbUpdate { profile_membership: __sdk::TableUpdate, profile_played_world: __sdk::TableUpdate, profile_recharge_order: __sdk::TableUpdate, + profile_recharge_product_config: __sdk::TableUpdate, profile_redeem_code: __sdk::TableUpdate, profile_redeem_code_usage: __sdk::TableUpdate, profile_referral_relation: __sdk::TableUpdate, @@ -2224,6 +2243,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_recharge_order" => db_update.profile_recharge_order.append( profile_recharge_order_table::parse_table_update(table_update)?, ), + "profile_recharge_product_config" => { + db_update.profile_recharge_product_config.append( + profile_recharge_product_config_table::parse_table_update(table_update)?, + ) + } "profile_redeem_code" => db_update .profile_redeem_code .append(profile_redeem_code_table::parse_table_update(table_update)?), @@ -2627,6 +2651,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_recharge_order, ) .with_updates_by_pk(|row| &row.order_id); + diff.profile_recharge_product_config = cache + .apply_diff_to_table::( + "profile_recharge_product_config", + &self.profile_recharge_product_config, + ) + .with_updates_by_pk(|row| &row.product_id); diff.profile_redeem_code = cache .apply_diff_to_table::( "profile_redeem_code", @@ -2969,6 +2999,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_recharge_order" => db_update .profile_recharge_order .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_recharge_product_config" => db_update + .profile_recharge_product_config + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_redeem_code" => db_update .profile_redeem_code .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3246,6 +3279,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_recharge_order" => db_update .profile_recharge_order .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_recharge_product_config" => db_update + .profile_recharge_product_config + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_redeem_code" => db_update .profile_redeem_code .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3427,6 +3463,7 @@ pub struct AppliedDiff<'r> { profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>, profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>, + profile_recharge_product_config: __sdk::TableAppliedDiff<'r, ProfileRechargeProductConfig>, profile_redeem_code: __sdk::TableAppliedDiff<'r, ProfileRedeemCode>, profile_redeem_code_usage: __sdk::TableAppliedDiff<'r, ProfileRedeemCodeUsage>, profile_referral_relation: __sdk::TableAppliedDiff<'r, ProfileReferralRelation>, @@ -3717,6 +3754,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_recharge_order, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_product_config", + &self.profile_recharge_product_config, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_redeem_code", &self.profile_redeem_code, @@ -4609,6 +4651,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_membership_table::register_table(client_cache); profile_played_world_table::register_table(client_cache); profile_recharge_order_table::register_table(client_cache); + profile_recharge_product_config_table::register_table(client_cache); profile_redeem_code_table::register_table(client_cache); profile_redeem_code_usage_table::register_table(client_cache); profile_referral_relation_table::register_table(client_cache); @@ -4699,6 +4742,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_membership", "profile_played_world", "profile_recharge_order", + "profile_recharge_product_config", "profile_redeem_code", "profile_redeem_code_usage", "profile_referral_relation", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs new file mode 100644 index 00000000..2da927d3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_table.rs @@ -0,0 +1,171 @@ +// 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_recharge_product_config_type::ProfileRechargeProductConfig; +use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; +use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_product_config`. +/// +/// Obtain a handle from the [`ProfileRechargeProductConfigTableAccess::profile_recharge_product_config`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_product_config()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_product_config().on_insert(...)`. +pub struct ProfileRechargeProductConfigTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_product_config`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeProductConfigTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeProductConfigTableHandle`], which mediates access to the table `profile_recharge_product_config`. + fn profile_recharge_product_config(&self) -> ProfileRechargeProductConfigTableHandle<'_>; +} + +impl ProfileRechargeProductConfigTableAccess for super::RemoteTables { + fn profile_recharge_product_config(&self) -> ProfileRechargeProductConfigTableHandle<'_> { + ProfileRechargeProductConfigTableHandle { + imp: self + .imp + .get_table::("profile_recharge_product_config"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeProductConfigInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeProductConfigDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeProductConfigTableHandle<'ctx> { + type Row = ProfileRechargeProductConfig; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeProductConfigInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeProductConfigInsertCallbackId { + ProfileRechargeProductConfigInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeProductConfigInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeProductConfigDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeProductConfigDeleteCallbackId { + ProfileRechargeProductConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeProductConfigDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeProductConfigUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeProductConfigTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeProductConfigUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeProductConfigUpdateCallbackId { + ProfileRechargeProductConfigUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeProductConfigUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `product_id` unique index on the table `profile_recharge_product_config`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeProductConfigProductIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_product_config().product_id().find(...)`. +pub struct ProfileRechargeProductConfigProductIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeProductConfigTableHandle<'ctx> { + /// Get a handle on the `product_id` unique index on the table `profile_recharge_product_config`. + pub fn product_id(&self) -> ProfileRechargeProductConfigProductIdUnique<'ctx> { + ProfileRechargeProductConfigProductIdUnique { + imp: self.imp.get_unique_constraint::("product_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeProductConfigProductIdUnique<'ctx> { + /// Find the subscribed row whose `product_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_recharge_product_config"); + _table.add_unique_constraint::("product_id", |row| &row.product_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 `ProfileRechargeProductConfig`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_product_configQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeProductConfig`. + fn profile_recharge_product_config( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_product_configQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_product_config( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_product_config") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs new file mode 100644 index 00000000..810e7259 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_product_config_type.rs @@ -0,0 +1,101 @@ +// 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}; + +use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; +use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeProductConfig { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub created_by: String, + pub created_at: __sdk::Timestamp, + pub updated_by: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRechargeProductConfig { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeProductConfig`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeProductConfigCols { + pub product_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub price_cents: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col< + ProfileRechargeProductConfig, + RuntimeProfileRechargeProductKind, + >, + pub points_amount: __sdk::__query_builder::Col, + pub bonus_points: __sdk::__query_builder::Col, + pub duration_days: __sdk::__query_builder::Col, + pub badge_label: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col, + pub tier: + __sdk::__query_builder::Col, + pub enabled: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub created_by: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_by: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeProductConfig { + type Cols = ProfileRechargeProductConfigCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeProductConfigCols { + product_id: __sdk::__query_builder::Col::new(table_name, "product_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + price_cents: __sdk::__query_builder::Col::new(table_name, "price_cents"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + points_amount: __sdk::__query_builder::Col::new(table_name, "points_amount"), + bonus_points: __sdk::__query_builder::Col::new(table_name, "bonus_points"), + duration_days: __sdk::__query_builder::Col::new(table_name, "duration_days"), + badge_label: __sdk::__query_builder::Col::new(table_name, "badge_label"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + tier: __sdk::__query_builder::Col::new(table_name, "tier"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeProductConfig`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeProductConfigIxCols { + pub product_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeProductConfig { + type IxCols = ProfileRechargeProductConfigIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeProductConfigIxCols { + product_id: __sdk::__query_builder::IxCol::new(table_name, "product_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeProductConfig {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_input_type.rs new file mode 100644 index 00000000..5e731e56 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_input_type.rs @@ -0,0 +1,15 @@ +// 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 RuntimeProfileRechargeProductAdminListInput { + pub admin_user_id: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeProductAdminListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_procedure_result_type.rs new file mode 100644 index 00000000..3f234e80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_list_procedure_result_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}; + +use super::runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeProductAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeProductAdminListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_procedure_result_type.rs new file mode 100644 index 00000000..ee1f9ad7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_procedure_result_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}; + +use super::runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeProductAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeProductAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs new file mode 100644 index 00000000..caf508bf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_admin_upsert_input_type.rs @@ -0,0 +1,31 @@ +// 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}; + +use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; +use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeProductAdminUpsertInput { + pub admin_user_id: String, + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeProductAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs new file mode 100644 index 00000000..9433ee19 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_config_snapshot_type.rs @@ -0,0 +1,33 @@ +// 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}; + +use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; +use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeProductConfigSnapshot { + pub product_id: String, + pub title: String, + pub price_cents: u64, + pub kind: RuntimeProfileRechargeProductKind, + pub points_amount: u64, + pub bonus_points: u64, + pub duration_days: u32, + pub badge_label: String, + pub description: String, + pub tier: RuntimeProfileMembershipTier, + pub enabled: bool, + pub sort_order: i32, + pub created_by: String, + pub created_at_micros: i64, + pub updated_by: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeProductConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 5e86b21f..3ecd0d1f 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -657,6 +657,78 @@ impl SpacetimeClient { .await } + pub async fn admin_list_profile_recharge_products( + &self, + admin_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = + build_runtime_profile_recharge_product_admin_list_input(admin_user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_list_profile_recharge_products_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_product_admin_list_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn admin_upsert_profile_recharge_product( + &self, + admin_user_id: String, + product_id: String, + title: String, + price_cents: u64, + kind: module_runtime::RuntimeProfileRechargeProductKind, + points_amount: u64, + bonus_points: u64, + duration_days: u32, + badge_label: String, + description: String, + tier: module_runtime::RuntimeProfileMembershipTier, + enabled: bool, + sort_order: i32, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_recharge_product_admin_upsert_input( + admin_user_id, + product_id, + title, + price_cents, + kind, + points_amount, + bonus_points, + duration_days, + badge_label, + description, + tier, + enabled, + sort_order, + updated_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_recharge_product_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_product_admin_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn admin_upsert_profile_redeem_code( &self, admin_user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index e93c4566..8d7e78c3 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -193,6 +193,7 @@ macro_rules! migration_tables { public_work_play_daily_stat, public_work_like, profile_membership, + profile_recharge_product_config, profile_recharge_order, profile_feedback_submission, profile_save_archive, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index f47134fe..c4ba135f 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -6,6 +6,7 @@ const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20; const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration"; const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task"; +const PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID: &str = "system:recharge-product"; const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login"; const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile"; @@ -328,6 +329,28 @@ pub struct ProfileMembership { pub(crate) updated_at: Timestamp, } +#[spacetimedb::table(accessor = profile_recharge_product_config)] +#[derive(Clone)] +pub struct ProfileRechargeProductConfig { + #[primary_key] + pub(crate) product_id: String, + pub(crate) title: String, + pub(crate) price_cents: u64, + pub(crate) kind: RuntimeProfileRechargeProductKind, + pub(crate) points_amount: u64, + pub(crate) bonus_points: u64, + pub(crate) duration_days: u32, + pub(crate) badge_label: String, + pub(crate) description: String, + pub(crate) tier: RuntimeProfileMembershipTier, + pub(crate) enabled: bool, + pub(crate) sort_order: i32, + pub(crate) created_by: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_by: String, + pub(crate) updated_at: Timestamp, +} + #[spacetimedb::table( accessor = profile_recharge_order, index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])), @@ -655,6 +678,44 @@ pub fn admin_disable_profile_task_config( } } +#[spacetimedb::procedure] +pub fn admin_list_profile_recharge_products( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeProductAdminListInput, +) -> RuntimeProfileRechargeProductAdminListProcedureResult { + match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) { + Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileRechargeProductAdminListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_recharge_product( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeProductAdminUpsertInput, +) -> RuntimeProfileRechargeProductAdminProcedureResult { + match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) { + Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRechargeProductAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 #[spacetimedb::procedure] pub fn grant_new_user_registration_wallet_reward( @@ -1454,6 +1515,22 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) mod tests { use super::*; + #[test] + fn point_recharge_display_is_resolved_per_product() { + let products = runtime_profile_recharge_point_products(); + let repeated = resolve_profile_recharge_product_display(products[0].clone(), true); + let untouched = resolve_profile_recharge_product_display(products[1].clone(), false); + + assert_eq!(repeated.product_id, "points_60"); + assert_eq!(repeated.bonus_points, 0); + 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泥点"); + } + #[test] fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { assert!(should_skip_existing_tracking_event_id(true)); @@ -2091,8 +2168,8 @@ fn create_profile_recharge_order_record( input.created_at_micros, ) .map_err(|error| error.to_string())?; - let product = runtime_profile_recharge_product_by_id(&validated_input.product_id) - .ok_or_else(|| "recharge.product_id 不存在".to_string())?; + let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id) + .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?; let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); let should_settle_immediately = validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; @@ -2201,7 +2278,7 @@ fn mark_profile_recharge_order_paid_record( return Err("profile_recharge_order 当前状态不能确认支付".to_string()); } - let product = runtime_profile_recharge_product_by_id(&order.product_id) + let product = profile_recharge_product_by_id(ctx, &order.product_id) .ok_or_else(|| "recharge.product_id 不存在".to_string())?; let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( @@ -2238,7 +2315,7 @@ fn apply_profile_recharge_purchase( ) -> Result<(i64, Option), String> { match product.kind { RuntimeProfileRechargeProductKind::Points => { - let has_recharged = has_profile_points_recharged(ctx, user_id); + let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id); let points_delta = resolve_runtime_profile_points_recharge_delta(product, has_recharged); apply_profile_wallet_delta( @@ -2907,6 +2984,7 @@ fn build_profile_recharge_center_snapshot( ctx: &ReducerContext, user_id: &str, ) -> RuntimeProfileRechargeCenterSnapshot { + ensure_default_profile_recharge_product_config(ctx); let wallet_balance = ctx .db .profile_dashboard_state() @@ -2916,13 +2994,31 @@ fn build_profile_recharge_center_snapshot( .unwrap_or(0); let has_points_recharged = has_profile_points_recharged(ctx, user_id); + let mut point_products = Vec::new(); + let mut membership_products = Vec::new(); + for row in profile_recharge_product_config_rows(ctx, false) { + let product = build_profile_recharge_product_snapshot_from_config_row(&row); + match product.kind { + RuntimeProfileRechargeProductKind::Points => { + let has_product_recharged = + has_profile_product_recharged(ctx, user_id, &product.product_id); + point_products.push(resolve_profile_recharge_product_display( + product, + has_product_recharged, + )); + } + RuntimeProfileRechargeProductKind::Membership => { + membership_products.push(product); + } + } + } RuntimeProfileRechargeCenterSnapshot { user_id: user_id.to_string(), wallet_balance, membership: build_profile_membership_snapshot(ctx, user_id), - point_products: resolve_runtime_profile_recharge_point_products(has_points_recharged), - membership_products: runtime_profile_recharge_membership_products(), + point_products, + membership_products, benefits: runtime_profile_membership_benefits(), latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), @@ -3052,6 +3148,21 @@ fn list_profile_task_config_snapshots( Ok(entries) } +fn list_profile_recharge_product_config_snapshots( + ctx: &ReducerContext, + input: RuntimeProfileRechargeProductAdminListInput, +) -> Result, String> { + let _validated_input = + build_runtime_profile_recharge_product_admin_list_input(input.admin_user_id) + .map_err(|error| error.to_string())?; + ensure_default_profile_recharge_product_config(ctx); + + Ok(profile_recharge_product_config_rows(ctx, true) + .iter() + .map(build_profile_recharge_product_config_snapshot_from_row) + .collect()) +} + fn admin_list_profile_redeem_code_records( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminListInput, @@ -3097,6 +3208,74 @@ fn admin_list_profile_invite_code_records( Ok(entries) } +fn upsert_profile_recharge_product_config_record( + ctx: &ReducerContext, + input: RuntimeProfileRechargeProductAdminUpsertInput, +) -> Result { + let validated_input = build_runtime_profile_recharge_product_admin_upsert_input( + input.admin_user_id, + input.product_id, + input.title, + input.price_cents, + input.kind, + input.points_amount, + input.bonus_points, + input.duration_days, + input.badge_label, + input.description, + input.tier, + input.enabled, + input.sort_order, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + ensure_default_profile_recharge_product_config(ctx); + + let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); + let existing = ctx + .db + .profile_recharge_product_config() + .product_id() + .find(&validated_input.product_id); + if let Some(row) = existing.as_ref() { + ctx.db + .profile_recharge_product_config() + .product_id() + .delete(&row.product_id); + } + + let inserted = ctx + .db + .profile_recharge_product_config() + .insert(ProfileRechargeProductConfig { + product_id: validated_input.product_id, + title: validated_input.title, + price_cents: validated_input.price_cents, + kind: validated_input.kind, + points_amount: validated_input.points_amount, + bonus_points: validated_input.bonus_points, + duration_days: validated_input.duration_days, + badge_label: validated_input.badge_label, + description: validated_input.description, + tier: validated_input.tier, + enabled: validated_input.enabled, + sort_order: validated_input.sort_order, + created_by: existing + .as_ref() + .map(|row| row.created_by.clone()) + .unwrap_or_else(|| validated_input.admin_user_id.clone()), + created_at: existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at), + updated_by: validated_input.admin_user_id, + updated_at, + }); + Ok(build_profile_recharge_product_config_snapshot_from_row( + &inserted, + )) +} + fn upsert_profile_task_config_record( ctx: &ReducerContext, input: RuntimeProfileTaskConfigAdminUpsertInput, @@ -3518,6 +3697,96 @@ 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 { + return; + } + + let now = ctx.timestamp; + for (sort_order, product) in runtime_profile_recharge_point_products() + .into_iter() + .chain(runtime_profile_recharge_membership_products()) + .enumerate() + { + ctx.db + .profile_recharge_product_config() + .insert(ProfileRechargeProductConfig { + product_id: product.product_id, + title: product.title, + price_cents: product.price_cents, + kind: product.kind, + points_amount: product.points_amount, + bonus_points: product.bonus_points, + duration_days: product.duration_days, + badge_label: product.badge_label, + description: product.description, + tier: product.tier, + enabled: true, + sort_order: sort_order as i32, + created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + created_at: now, + updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), + updated_at: now, + }); + } +} + +fn profile_recharge_product_config_rows( + ctx: &ReducerContext, + include_disabled: bool, +) -> Vec { + ensure_default_profile_recharge_product_config(ctx); + let mut rows = ctx + .db + .profile_recharge_product_config() + .iter() + .filter(|row| include_disabled || row.enabled) + .collect::>(); + rows.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.product_id.cmp(&right.product_id)) + }); + rows +} + +fn profile_recharge_product_by_id( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + ensure_default_profile_recharge_product_config(ctx); + ctx.db + .profile_recharge_product_config() + .product_id() + .find(&product_id.to_string()) + .map(|row| build_profile_recharge_product_snapshot_from_config_row(&row)) +} + +fn enabled_profile_recharge_product_by_id( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + ensure_default_profile_recharge_product_config(ctx); + ctx.db + .profile_recharge_product_config() + .product_id() + .find(&product_id.to_string()) + .filter(|row| row.enabled) + .map(|row| build_profile_recharge_product_snapshot_from_config_row(&row)) +} + +fn resolve_profile_recharge_product_display( + mut product: RuntimeProfileRechargeProductSnapshot, + has_product_recharged: bool, +) -> RuntimeProfileRechargeProductSnapshot { + if product.kind == RuntimeProfileRechargeProductKind::Points && has_product_recharged { + product.bonus_points = 0; + product.badge_label.clear(); + product.description = product.title.clone(); + } + product +} + fn build_profile_membership_snapshot( ctx: &ReducerContext, user_id: &str, @@ -3754,9 +4023,19 @@ fn apply_profile_wallet_signed_delta( } fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db.profile_wallet_ledger().iter().any(|row| { + ctx.db.profile_recharge_order().iter().any(|row| { row.user_id == user_id - && row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge + && row.kind == RuntimeProfileRechargeProductKind::Points + && row.status == RuntimeProfileRechargeOrderStatus::Paid + }) +} + +fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool { + ctx.db.profile_recharge_order().iter().any(|row| { + row.user_id == user_id + && row.product_id == product_id + && row.kind == RuntimeProfileRechargeProductKind::Points + && row.status == RuntimeProfileRechargeOrderStatus::Paid }) } @@ -3894,6 +4173,46 @@ fn build_profile_task_config_snapshot_from_row( } } +fn build_profile_recharge_product_config_snapshot_from_row( + row: &ProfileRechargeProductConfig, +) -> RuntimeProfileRechargeProductConfigSnapshot { + RuntimeProfileRechargeProductConfigSnapshot { + product_id: row.product_id.clone(), + title: row.title.clone(), + price_cents: row.price_cents, + kind: row.kind, + points_amount: row.points_amount, + bonus_points: row.bonus_points, + duration_days: row.duration_days, + badge_label: row.badge_label.clone(), + description: row.description.clone(), + tier: row.tier, + enabled: row.enabled, + sort_order: row.sort_order, + created_by: row.created_by.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_by: row.updated_by.clone(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_recharge_product_snapshot_from_config_row( + row: &ProfileRechargeProductConfig, +) -> RuntimeProfileRechargeProductSnapshot { + RuntimeProfileRechargeProductSnapshot { + product_id: row.product_id.clone(), + title: row.title.clone(), + price_cents: row.price_cents, + kind: row.kind, + points_amount: row.points_amount, + bonus_points: row.bonus_points, + duration_days: row.duration_days, + badge_label: row.badge_label.clone(), + description: row.description.clone(), + tier: row.tier, + } +} + fn build_profile_recharge_order_snapshot_from_row( row: &ProfileRechargeOrder, ) -> RuntimeProfileRechargeOrderSnapshot { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 583fbdea..7db38520 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1039,7 +1039,7 @@ test('profile recharge modal buys points through mock channel outside mini progr expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); -test('profile recharge modal hides first bonus display after points recharge', async () => { +test('profile recharge modal trusts per-product first bonus display after points recharge', async () => { const user = userEvent.setup(); mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({ walletBalance: 60, @@ -1076,8 +1076,8 @@ test('profile recharge modal hides first bonus display after points recharge', a const rechargeDialog = await screen.findByText('账户充值'); expect(rechargeDialog).toBeTruthy(); expect(screen.getByRole('button', { name: /60泥点/u })).toBeTruthy(); - expect(screen.queryByText('首充双倍')).toBeNull(); - expect(screen.queryByText('60+60泥点')).toBeNull(); + expect(screen.getByText('首充双倍')).toBeTruthy(); + expect(screen.getByText('60+60泥点')).toBeTruthy(); }); test('profile recharge modal posts requestPayment params in mini program web-view', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 2eeb53cb..1f9a43b1 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -2498,20 +2498,16 @@ async function confirmWechatRechargeOrderUntilSettled( function RechargeProductCard({ product, - hasPointsRecharged, submittingProductId, onBuy, }: { product: ProfileRechargeProduct; - hasPointsRecharged: boolean; submittingProductId: string | null; onBuy: (product: ProfileRechargeProduct) => void; }) { const submitting = submittingProductId === product.productId; - const effectiveBonusPoints = - product.kind === 'points' && hasPointsRecharged ? 0 : product.bonusPoints; - const badgeLabel = - product.kind === 'points' && hasPointsRecharged ? '' : product.badgeLabel; + const effectiveBonusPoints = product.bonusPoints; + const badgeLabel = product.badgeLabel; const value = product.kind === 'points' ? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点` @@ -2646,7 +2642,6 @@ function ProfileRechargeModal({