diff --git a/.prettierignore b/.prettierignore index 5a4798841..446fa63d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,9 +2,10 @@ dist node_modules .git .codex-logs -public/Icons -# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写 +# ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。 +# 一律以原始输出提交,禁止 prettier 二次改写。 packages/shared/src/contracts/generated/ +public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ apps/ai-game-creator-shell/src/view/project-development/chat/generated/ apps/ai-game-creator-shell/src/services/generated/ diff --git a/CONTEXT.md b/CONTEXT.md index 00f7c178e..460a1abfc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -287,6 +287,56 @@ _Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另 聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 +### 泥点与会员计费 + +**每日免费泥点**: +账号每天按固定额度自动获得的泥点,只在北京时间当天有效,当日 24 点清零后重新发放;不受会员状态影响。 +_Avoid_: 永久赠送、会员额度、可累计的每日额度 + +**月度泥点**: +会员在当前会员账期内可用的泥点,只在本期有效;换期时本期余额清零并由新一期额度覆盖,会员到期时一并清零。 +_Avoid_: 会员点数、周期点数、限时点数、永久可用的会员赠送 + +**永久泥点**: +不过期、不受会员状态影响的泥点,来源是充值与非会员奖励,创作扣点与月度泥点完全一致。 +_Avoid_: 不限时泥点、充值余额、无限泥点、免费泥点 + +**永久余数**: +当前实现中「钱包总额扣除每日免费泥点与月度泥点之后剩下的部分」,即永久泥点在存储层的临时形态,不表示一种来源。 +_Avoid_: 独立余额、第四个池、充值本金 + +**会员账期**: +会员权益的一期有效期,从开通日时刻起按自然月推进到同一天的下一月;年付包含 12 期。 +_Avoid_: 自然月、固定 30 天、账单周期 + +**补差升级**: +在会员有效期内升到更高档位,只补付本期剩余时间段与新档位后续各期的差价,并补发当前期额度差额;已用泥点不返还。 +_Avoid_: 套餐切换、退订重买、即时月转年 + +**可用总额**: +界面上的「现在可用」口径,等于月度泥点加永久泥点,不含每日免费泥点。 +_Avoid_: 钱包总额、总泥点、含每日免费的总和 + +**会员档位**: +会员等级身份,取值为「非会员」与 `Starter` / `Plus` / `Pro` / `Max`,决定每期泥点额度、可用的模型范围、同时运行的独立任务数与升级比较次序;每个档位分月付与年付两种周期。 +_Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串、用 `tier` 当档位的规范词 + +**会员计划目录**: +会员档位可配置的价格与权益集合:月价、年价、每期泥点、模型权限、并发上限、排序与上下架。 +_Avoid_: 把价格写死在代码、按档位名字硬编码权益 + +**模型权限**: +会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串 + +**并发上限**: +同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入 + +**独立生成任务**: +用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。 +_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发) + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9c3470426..241019468 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -74,6 +74,7 @@ import type { AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, @@ -90,6 +91,8 @@ import type { EditorGenerationPricingConfigPayload, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminListResponse, @@ -795,6 +798,27 @@ export function upsertProfileRechargeProduct( ); } +export function listProfileMembershipPlans(token: string) { + return request( + '/admin/api/profile/membership-plans', + { token }, + ); +} + +export function upsertProfileMembershipPlan( + token: string, + payload: AdminUpsertProfileMembershipPlanRequest, +) { + return request( + '/admin/api/profile/membership-plans', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminRechargeOrders( token: string, query: AdminRechargeOrderListQuery = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index cc3ea7076..b47ed6fcc 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1,3 +1,12 @@ +import type { + ProfileMembershipChangeKindToken, + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, + ProfileMembershipPlanResponse, + ProfileMembershipStatus, +} from '@genarrative/shared'; + export interface ApiMeta { apiVersion?: string; requestId?: string; @@ -381,16 +390,13 @@ 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' - | 'starter' - | 'basic' - | 'pro' - | 'ultimate'; - +/** 会员档位 / 周期 / 权限:统一来自 `@genarrative/shared` 的生成类型,禁止手写字面量。 */ +export type { + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, + ProfileMembershipStatus, +}; export interface AdminTrackingEventListQuery { eventKey?: string; userId?: string; @@ -678,15 +684,8 @@ export interface AdminUpsertProfileRechargeProductRequest { priceCents: number; kind: ProfileRechargeProductKind; pointsAmount: number; - bonusPoints: number; - durationDays: number; badgeLabel?: string | null; description?: string | null; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; } @@ -775,11 +774,6 @@ export interface ProfileRechargeProductConfigAdminResponse { durationDays: number; badgeLabel: string; description: string; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; createdBy: string; @@ -792,6 +786,14 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } +/** 会员档位目录行;直接复用 `@genarrative/shared` 的后端生成类型,避免手工漂移。 */ +export type ProfileMembershipPlanAdminResponse = ProfileMembershipPlanResponse; + +export type { + AdminUpsertProfileMembershipPlanRequest, + ProfileMembershipPlanAdminListResponse, +} from '@genarrative/shared'; + export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; @@ -906,7 +908,8 @@ export interface AdminProfileWalletPayload { totalBalance: number; spendableBalance: number; dailyFreePoints: number; - membershipLimitedPoints: number; + /** 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 */ + monthlyPoints: number; permanentPoints: number; heldPoints: number; refundDebtPoints: number; @@ -950,6 +953,25 @@ export interface AdminRechargeRefundHoldPayload { updatedAtMicros: number; } +export interface AdminMembershipOrderChangePayload { + /** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */ + changeKind: ProfileMembershipChangeKindToken; + planBefore?: ProfileMembershipPlan | null; + planAfter?: ProfileMembershipPlan | null; + cycleKindBefore?: ProfileMembershipCycleKind | null; + cycleKindAfter?: ProfileMembershipCycleKind | null; + periodPointsBefore?: number | null; + periodPointsAfter?: number | null; + grantedPointsDelta: number; + cycleIndex?: number | null; + expiresAtBeforeMicros?: number | null; + expiresAtAfterMicros?: number | null; + cycleResetsAtBeforeMicros?: number | null; + cycleResetsAtAfterMicros?: number | null; + /** 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);人工复核时原样展示。 */ + priceBreakdownJson?: string | null; +} + export interface AdminRechargeOrderEntryPayload { orderId: string; userId: string; @@ -977,6 +999,8 @@ export interface AdminRechargeOrderEntryPayload { remainingRefundableCents: number; refundEligible: boolean; refundBlockReasonCode?: string | null; + /** 会员购买 / 升级快照;非会员订单为 null。 */ + membershipChange?: AdminMembershipOrderChangePayload | null; } export interface AdminRechargeOrderListResponse { @@ -1003,9 +1027,26 @@ export interface AdminUserDetailResponse { cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; + membership: AdminProfileMembershipPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; } +/** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */ +export interface AdminProfileMembershipPayload { + plan: ProfileMembershipPlan; + /** 与后端 `RuntimeProfileMembershipStatus` 对齐:`normal` / `active`。 */ + status: ProfileMembershipStatus; + cycleKind: ProfileMembershipCycleKind; + cycleIndex: number; + cycleCount: number; + cycleGrantedPoints: number; + cycleRemainingPoints: number; + startedAtMicros?: number | null; + expiresAtMicros?: number | null; + cycleResetsAtMicros?: number | null; + updatedAtMicros?: number | null; +} + export interface AdminUserConsumptionReconcileRequest { userId: string; } @@ -1109,6 +1150,8 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; +/** 复用 `@genarrative/shared` 的生成类型,与后端 `ProfileMembershipModelAccessToken` 同源。 */ +export type AdminAgcModelAccess = ProfileMembershipModelAccess; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' @@ -1122,6 +1165,8 @@ export interface AdminAgcModel { agentMode: AdminAgcAgentMode; /** 上游线协议,与执行器正交。 */ protocol?: AdminAgcModelProtocol; + /** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */ + access?: AdminAgcModelAccess; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 52957c245..7e7a5fe6f 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -9,6 +9,7 @@ import { } from '../api/adminApiClient'; import type { AdminSessionPayload, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminResponse, ProfileTaskConfigAdminResponse, ProfileWalletConfigAdminResponse, @@ -35,6 +36,7 @@ import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; +import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; @@ -69,6 +71,8 @@ export function AdminApp() { useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); + const [membershipPlanResult, setMembershipPlanResult] = + useState(null); const accessibleRoutes = useMemo( () => (admin ? getAccessibleAdminRoutes(admin) : []), [admin], @@ -84,6 +88,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -174,6 +179,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setLoginNotice(''); setStatus('authenticated'); }, @@ -292,6 +298,14 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {activeRouteId === 'membership-plans' ? ( + + ) : null} {activeRouteId === 'recharge-orders' ? ( void handleConsumptionReconcile()} /> + +
{order.productTitle || order.productId} {order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'} {order.pointsDelta} 泥点 - {formatMoney(order.cumulativeSuccessRefundCents)} + {formatAdminCurrencyCents( + order.cumulativeSuccessRefundCents, + )} 欠账 {order.unrecoveredPoints} 泥点 {formatOrderStatus(order.status)} @@ -434,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) { value: ( <> {typeof detail.cumulativeRechargedCents === 'number' - ? formatMoney(detail.cumulativeRechargedCents) + ? formatAdminCurrencyCents(detail.cumulativeRechargedCents) : '未知'} ), @@ -478,7 +489,7 @@ function WalletSection({ ['可消费', wallet.spendableBalance], ['永久泥点', wallet.permanentPoints], ['每日免费', wallet.dailyFreePoints], - ['会员限时', wallet.membershipLimitedPoints], + ['月度泥点', wallet.monthlyPoints], ['退款占用', wallet.heldPoints], ['退款欠账', wallet.refundDebtPoints], ['历史花费', historicalConsumedPoints], @@ -527,8 +538,49 @@ function WalletSection({ ); } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; +function MembershipSection({ + membership, +}: { + membership: AdminProfileMembershipPayload; +}) { + const metrics: Array<[string, string]> = [ + ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], + ['状态', formatMembershipStatus(membership.status) || '-'], + [ + '账期', + // 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。 + membership.status === 'active' + ? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期` + : '-', + ], + ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], + ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], + [ + '到期', + membership.expiresAtMicros + ? formatMicros(membership.expiresAtMicros) + : '-', + ], + [ + '账期重置', + membership.cycleResetsAtMicros + ? formatMicros(membership.cycleResetsAtMicros) + : '-', + ], + ]; + return ( +
+ +
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); } function formatMicros(value: number) { diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts new file mode 100644 index 000000000..3a903c5fb --- /dev/null +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -0,0 +1,44 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipStatus, +} from '../api/adminApiTypes'; + +/** 会员档位展示名;`normal` 即非会员。 */ +export const membershipPlanLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** 账期展示名;有效期内不支持月年互换。 */ +export const membershipCycleKindLabels: Record< + ProfileMembershipCycleKind, + string +> = { + monthly: '月付', + yearly: '年付', +}; + +/** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */ +export const membershipStatusLabels: Record = { + normal: '非会员', + active: '生效中', +}; + +/** 把后端的档位 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipPlan(plan: string): string { + return membershipPlanLabels[plan as ProfileMembershipPlan] ?? plan; +} + +/** 把后端的账期 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipCycleKind(kind: string): string { + return membershipCycleKindLabels[kind as ProfileMembershipCycleKind] ?? kind; +} + +/** 把后端的会员状态 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipStatus(status: string): string { + return membershipStatusLabels[status as ProfileMembershipStatus] ?? status; +} diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index feb4ce9d3..3279a5342 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () => modelId: '', enabled: true, agentMode: 'cc', + access: 'basic', }); }); }); +test('权限档可按模型单独选择并随目录保存', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'gpt-6-astra', + enabled: true, + agentMode: 'codex' as const, + access: 'basic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + + const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement; + expect(access.value).toBe('basic'); + fireEvent.change(access, { target: { value: 'full' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ access: 'full' }); + }); +}); + test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { const catalog = { revision: 3, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index b009aa222..9e14400cb 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -118,6 +118,7 @@ export function AdminAgcModelsPage({ { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, { key: 'protocol', label: '协议' }, + { key: 'access', label: '权限档' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -159,6 +160,7 @@ export function AdminAgcModelsPage({ enabled: true, agentMode: 'codex', protocol: 'openai_responses', + access: 'basic', }, ], }, @@ -246,6 +248,21 @@ export function AdminAgcModelsPage({ + + + = { public_work_play_daily_stat: '公开作品日游玩统计', public_work_like: '公开作品点赞', profile_membership: '会员状态', + profile_membership_plan: '会员档位目录', profile_recharge_order: '充值订单', profile_feedback_submission: '反馈提交', profile_save_archive: '存档记录', @@ -2578,6 +2579,7 @@ const databaseTableDescriptionMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计表', public_work_like: '公开作品点赞表', profile_membership: '会员状态表', + profile_membership_plan: '会员档位价格与权益目录表', profile_recharge_order: '充值订单表', profile_feedback_submission: '反馈提交记录表', profile_save_archive: '用户存档记录表', diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx new file mode 100644 index 000000000..c9198234f --- /dev/null +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -0,0 +1,463 @@ +import { + AdminAlert, + AdminButton, + AdminField, + AdminFormRow, + AdminInfoList, + AdminListPanel, + AdminPage, + AdminPageHeading, + AdminPanel, + formatAdminCurrencyCents, +} from '@genarrative/shared/components'; +import { RefreshCcw, Save } from 'lucide-react'; +import { FormEvent, useEffect, useState } from 'react'; + +import { + listProfileMembershipPlans, + upsertProfileMembershipPlan, +} from '../api/adminApiClient'; +import type { + ProfileMembershipModelAccess, + ProfileMembershipPlan, + ProfileMembershipPlanAdminResponse, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + formatMembershipPlan, + membershipPlanLabels, +} from '../config/membershipDisplay'; +import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils'; + +interface AdminMembershipPlanPageProps { + token: string; + result: ProfileMembershipPlanAdminResponse | null; + onUnauthorized: (message?: string) => void; + onResultChange: (result: ProfileMembershipPlanAdminResponse) => void; +} + +/** 模型权限的唯一文案表:表单选项与列表摘要共用,避免两处手工映射漂移。 */ +const modelAccessLabels: Record = { + basic: '仅基础模型', + full: '基础及高性能模型', +}; + +const modelAccessOptions: Array<{ + value: ProfileMembershipModelAccess; + label: string; +}> = (Object.keys(modelAccessLabels) as ProfileMembershipModelAccess[]).map( + (value) => ({ value, label: modelAccessLabels[value] }), +); + +/** + * 会员档位目录。 + * + * 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、 + * 下单金额重算、补差升级报价都读它。`rank` 由代码内的档位目录决定,后台不能改, + * 因此这里只读展示。模型权限与并发上限本期只落字段、只用于展示,服务端不做拦截。 + */ +export function AdminMembershipPlanPage({ + token, + result, + onUnauthorized, + onResultChange, +}: AdminMembershipPlanPageProps) { + const [entries, setEntries] = useState( + [], + ); + const [plan, setPlan] = useState('starter'); + const [title, setTitle] = useState(''); + const [monthPriceCents, setMonthPriceCents] = useState('0'); + const [yearPriceCents, setYearPriceCents] = useState('0'); + const [periodPoints, setPeriodPoints] = useState('0'); + const [modelAccess, setModelAccess] = + useState('basic'); + const [concurrentJobLimit, setConcurrentJobLimit] = useState('1'); + const [unlimitedConcurrencyThreshold, setUnlimitedConcurrencyThreshold] = + useState(0); + const [enabled, setEnabled] = useState(false); + const [rankLabel, setRankLabel] = 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 refreshPlans(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshPlans() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileMembershipPlans(token); + // 与保存后的排序保持一致:列表按升级基准 `rank` 展示。 + const sortedEntries = sortPlanEntries(response.entries); + setEntries(sortedEntries); + setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold); + const firstEntry = + sortedEntries.find((entry) => entry.plan === plan) ?? 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 normalizedMonthPriceCents = + parsePositiveIntegerOrZero(monthPriceCents); + const normalizedYearPriceCents = parsePositiveIntegerOrZero(yearPriceCents); + const normalizedConcurrentJobLimit = + parsePositiveIntegerOrZero(concurrentJobLimit); + const normalizedPeriodPoints = parsePositiveIntegerOrZero(periodPoints); + if (plan !== 'normal') { + if (!normalizedMonthPriceCents || !normalizedYearPriceCents) { + setErrorMessage('月价与年价必须是大于 0 的整数分'); + return; + } + if (!normalizedPeriodPoints) { + setErrorMessage('每期泥点必须是大于 0 的整数'); + return; + } + if (!normalizedConcurrentJobLimit) { + setErrorMessage( + unlimitedConcurrencyThreshold > 0 + ? `并发上限必须是大于 0 的整数;${unlimitedConcurrencyThreshold} 表示不设上限` + : '并发上限必须是大于 0 的整数', + ); + return; + } + } + + const confirmed = await confirmWrite({ + action: '保存会员档位', + target: `${membershipPlanLabels[plan]}(${plan})`, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertProfileMembershipPlan(token, { + plan, + title: title.trim(), + monthPriceCents: normalizedMonthPriceCents, + yearPriceCents: normalizedYearPriceCents, + periodPoints: normalizedPeriodPoints, + modelAccess, + concurrentJobLimit: normalizedConcurrentJobLimit, + enabled, + }); + onResultChange(response); + upsertEntry(response); + fillForm(response); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function upsertEntry(next: ProfileMembershipPlanAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.plan !== next.plan); + return sortPlanEntries([...rest, next]); + }); + } + + function fillForm(entry: ProfileMembershipPlanAdminResponse) { + setPlan(entry.plan); + setTitle(entry.title); + setMonthPriceCents(String(entry.monthPriceCents)); + setYearPriceCents(String(entry.yearPriceCents)); + setPeriodPoints(String(entry.periodPoints)); + setModelAccess(entry.modelAccess); + setConcurrentJobLimit(String(entry.concurrentJobLimit)); + setEnabled(entry.enabled); + setRankLabel(String(entry.rank)); + } + + // 目录里还没有这一档时清空表单,避免把上一档的价格 / 权益误当成它的值提交。 + function clearForm(nextPlan: ProfileMembershipPlan) { + setPlan(nextPlan); + setTitle(''); + setMonthPriceCents('0'); + setYearPriceCents('0'); + setPeriodPoints('0'); + setModelAccess('basic'); + setConcurrentJobLimit('1'); + setEnabled(false); + setRankLabel(''); + } + + const isPurchasable = plan !== 'normal'; + + return ( + + + + ); +} + +function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) { + return [...entries].sort((left, right) => left.rank - right.rank); +} + +function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { + const modelLabel = modelAccessLabels[entry.modelAccess]; + const concurrency = entry.unlimitedConcurrency + ? '不设上限' + : `${entry.concurrentJobLimit} 个`; + return `${modelLabel} · 并发 ${concurrency}`; +} diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx index 1697c489a..4a95de136 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx @@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => { ); }); -test('账号配置页拒绝非正整数每日免费额度', async () => { +test('账号配置页拒绝负数或非整数每日免费额度', async () => { render( { (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) .disabled, ).toBe(true); + fireEvent.change(input, { target: { value: '-1' } }); + expect( + (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) + .disabled, + ).toBe(true); expect(upsertProfileWalletConfig).not.toHaveBeenCalled(); }); + +test('账号配置页允许把每日免费泥点配置为 0', async () => { + const user = userEvent.setup(); + vi.mocked(upsertProfileWalletConfig).mockResolvedValue({ + ...configResponse, + dailyFreePointsPerDay: 0, + }); + render( + , + ); + fireEvent.change(await screen.findByLabelText('每日免费泥点数'), { + target: { value: '0' }, + }); + await user.click(screen.getByRole('button', { name: '保存' })); + expect(screen.getByText('初始 100 泥点,每日免费 0 泥点')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => + expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', { + initialMudPoints: 100, + dailyFreePointsPerDay: 0, + }), + ); +}); diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx index 9b3b4ea08..3d157fcdc 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -18,7 +18,11 @@ import { } from '../api/adminApiClient'; import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError } from './pageUtils'; +import { + handlePageError, + parseNonNegativeInteger, + parsePositiveInteger, +} from './pageUtils'; interface AdminProfileWalletConfigPageProps { token: string; @@ -66,11 +70,11 @@ export function AdminProfileWalletConfigPage({ if (isSaving) { return; } - const normalizedDailyFreePointsPerDay = parsePositiveInteger( + const normalizedDailyFreePointsPerDay = parseNonNegativeInteger( dailyFreePointsPerDay, ); - if (!normalizedDailyFreePointsPerDay) { - setErrorMessage('每日免费泥点数必须是大于 0 的整数'); + if (normalizedDailyFreePointsPerDay === null) { + setErrorMessage('每日免费泥点数必须是不小于 0 的整数'); return; } @@ -139,7 +143,7 @@ export function AdminProfileWalletConfigPage({ @@ -200,8 +204,3 @@ export function AdminProfileWalletConfigPage({ ); } - -function parsePositiveInteger(value: string) { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; -} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 79f150653..8e7bffb85 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -81,7 +81,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { totalBalance: 60, spendableBalance: 60, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 60, heldPoints: 0, refundDebtPoints: 0, @@ -729,6 +729,43 @@ test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断 }); }); +test('会员订单在发放泥点列展示会员变更快照', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-membership', + productId: 'membership-pro-monthly', + productTitle: 'Pro 月付', + productKind: 'membership', + pointsDelta: 320, + membershipChange: { + changeKind: 'upgrade', + planBefore: 'plus', + planAfter: 'pro', + cycleKindBefore: 'monthly', + cycleKindAfter: 'monthly', + periodPointsBefore: 200, + periodPointsAfter: 520, + grantedPointsDelta: 320, + cycleIndex: 2, + expiresAtBeforeMicros: null, + expiresAtAfterMicros: 1_723_000_000_000_000, + cycleResetsAtBeforeMicros: null, + cycleResetsAtAfterMicros: 1_723_000_000_000_000, + priceBreakdownJson: '{"targetPlanPriceCents":3900}', + }, + }, + ], + }); + renderPage(); + + const row = (await screen.findByText('order-membership')).closest( + 'tr', + ) as HTMLElement; + expect(within(row).getByText('升级 Plus→Pro / 月付')).toBeTruthy(); +}); + function renderPage(token = 'admin-token') { return render( , diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 753311e58..1d5bf6c42 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -13,6 +13,7 @@ import { AdminPanelHeading, AdminTag, AdminTagList, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { AlertTriangle, @@ -35,6 +36,7 @@ import { resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { + AdminMembershipOrderChangePayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeRefundActionResponse, @@ -43,6 +45,10 @@ import type { } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { + formatMembershipCycleKind, + formatMembershipPlan, +} from '../config/membershipDisplay'; import { handlePageError } from './pageUtils'; interface AdminRechargeOrderPageProps { @@ -307,7 +313,7 @@ export function AdminRechargeOrderPage({ setRefundRequestId(requestId); const confirmed = await confirmWrite({ action: '发起微信退款并追回泥点', - target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`, + target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`, }); if (!confirmed || submitLockRef.current) { return; @@ -716,15 +722,19 @@ export function AdminRechargeOrderPage({
+ {refundOrder.membershipChange ? ( +
+ + + +
+ ) : null} +
预检后剩余可退, - value: <>{formatMoney(preview.remainingRefundableCents)}, + value: ( + <> + {formatAdminCurrencyCents( + preview.remainingRefundableCents, + )} + + ), }, ]} /> @@ -954,16 +995,32 @@ export function AdminRechargeOrderPage({ }, { label: <>本地订单总额, - value: <>{formatMoney(manualReviewTarget.order.amountCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.order.amountCents, + )} + + ), }, { label: <>退款订单总额, - value: <>{formatMoney(manualReviewTarget.refund.totalCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.totalCents, + )} + + ), }, { label: <>本次退款金额, value: ( - <>{formatMoney(manualReviewTarget.refund.refundCents)} + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.refundCents, + )} + ), }, ]} @@ -1077,14 +1134,19 @@ function RechargeOrderRow({ {formatOrderPaidAmount(order)} {order.paidAmountCents > 0 ? null : ( - 订单 {formatMoney(order.amountCents)} + 订单 {formatAdminCurrencyCents(order.amountCents)} )} {order.pointsDelta} 泥点 + {order.membershipChange ? ( + {formatMembershipChangeSummary(order.membershipChange)} + ) : null} - 累计 {formatMoney(order.cumulativeSuccessRefundCents)} + + 累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)} + 应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints} @@ -1115,7 +1177,8 @@ function RechargeOrderRow({ 可消费 {order.wallet.spendableBalance} - 永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints} + 永久 {order.wallet.permanentPoints} / 月度{' '} + {order.wallet.monthlyPoints} / 占用 {order.wallet.heldPoints} {order.wallet.manualFrozen ? 人工冻结 : null} @@ -1260,14 +1323,29 @@ function parseYuanToCents(value: string) { return Number.isSafeInteger(cents) && cents > 0 ? cents : null; } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; +function formatMembershipChangeSummary( + change: AdminMembershipOrderChangePayload, +): string { + const action = change.changeKind === 'upgrade' ? '升级' : '新购'; + const before = change.planBefore + ? formatMembershipPlan(change.planBefore) + : null; + const after = change.planAfter + ? formatMembershipPlan(change.planAfter) + : null; + const planPath = + before && after && before !== after + ? `${before}→${after}` + : (after ?? before ?? '-'); + const cycleKind = change.cycleKindAfter ?? change.cycleKindBefore; + const cycle = cycleKind ? ` / ${formatMembershipCycleKind(cycleKind)}` : ''; + return `${action} ${planPath}${cycle}`; } /** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { return order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'; } diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 6fbaa879a..a53ab5285 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -8,6 +8,8 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminCurrencyCents, + formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; @@ -16,13 +18,9 @@ import { listProfileRechargeProducts, upsertProfileRechargeProduct, } from '../api/adminApiClient'; -import type { - ProfileMembershipTier, - ProfileRechargeProductConfigAdminResponse, - ProfileRechargeProductKind, -} from '../api/adminApiTypes'; +import type { ProfileRechargeProductConfigAdminResponse } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; -import { handlePageError } from './pageUtils'; +import { handlePageError, parsePositiveInteger } from './pageUtils'; interface AdminRechargeProductPageProps { token: string; @@ -31,25 +29,24 @@ interface AdminRechargeProductPageProps { 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: 'starter', label: 'Starter' }, - { value: 'basic', label: 'Basic' }, - { value: 'pro', label: 'Pro' }, - { value: 'ultimate', label: 'Ultimate' }, - { value: 'month', label: '月卡' }, - { value: 'season', label: '季卡' }, - { value: 'year', label: '年卡' }, - ]; +/** 新建泥点商品时的表单默认值;列表为空时回填它,避免残留上一次选中项。 */ +const DEFAULT_RECHARGE_PRODUCT_FORM = { + productId: 'points_60', + title: '60泥点', + priceCents: '600', + pointsAmount: '60', + badgeLabel: '', + description: '60泥点', + enabled: true, + sortOrder: '0', +}; +/** + * 泥点充值商品。 + * + * 会员档位已迁到独立的「会员档位」页(`profile_membership_plan`),本页只维护充点商品。 + * 表单不再提供首充赠送 / 天数等已退役字段。 + */ export function AdminRechargeProductPage({ token, result, @@ -59,22 +56,26 @@ export function AdminRechargeProductPage({ 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('0'); - const [durationDays, setDurationDays] = useState('0'); - const [badgeLabel, setBadgeLabel] = useState(''); - const [description, setDescription] = useState('60泥点'); - const [tier, setTier] = useState('normal'); - const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0'); - const [membershipPeriodDays, setMembershipPeriodDays] = useState('0'); - const [membershipQueueLimit, setMembershipQueueLimit] = useState('0'); - const [membershipDiscountBps, setMembershipDiscountBps] = useState('0'); - const [enabled, setEnabled] = useState(true); - const [sortOrder, setSortOrder] = useState('0'); + const [productId, setProductId] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.productId, + ); + const [title, setTitle] = useState(DEFAULT_RECHARGE_PRODUCT_FORM.title); + const [priceCents, setPriceCents] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.priceCents, + ); + const [pointsAmount, setPointsAmount] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount, + ); + const [badgeLabel, setBadgeLabel] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel, + ); + const [description, setDescription] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.description, + ); + const [enabled, setEnabled] = useState(DEFAULT_RECHARGE_PRODUCT_FORM.enabled); + const [sortOrder, setSortOrder] = useState( + DEFAULT_RECHARGE_PRODUCT_FORM.sortOrder, + ); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); @@ -91,11 +92,17 @@ export function AdminRechargeProductPage({ setListErrorMessage(''); try { const response = await listProfileRechargeProducts(token); - const sortedEntries = sortProducts(response.entries); + // 中文注释:历史会员商品行已迁到「会员档位」页,本页只维护泥点商品,避免误展示为 0 + 0 泥点。 + const sortedEntries = sortProducts( + response.entries.filter((entry) => entry.kind === 'points'), + ); setEntries(sortedEntries); const firstEntry = sortedEntries[0]; if (firstEntry) { fillForm(firstEntry); + } else { + // 中文注释:列表为空时回填新建默认值,避免表单残留已删除商品的旧值被误保存。 + resetForm(); } } catch (error: unknown) { handlePageError(error, onUnauthorized, setListErrorMessage); @@ -111,6 +118,13 @@ export function AdminRechargeProductPage({ } setErrorMessage(''); + // 中文注释:价格分与基础泥点必须是正整数;空串/非法输入不能静默落库成 0。 + const normalizedPriceCents = parsePositiveInteger(priceCents); + const normalizedPointsAmount = parsePositiveInteger(pointsAmount); + if (normalizedPriceCents === null || normalizedPointsAmount === null) { + setErrorMessage('价格分与基础泥点必须是大于 0 的整数'); + return; + } const confirmed = await confirmWrite({ action: enabled ? '保存充值商品' : '停用充值商品', target: productId.trim(), @@ -124,33 +138,11 @@ export function AdminRechargeProductPage({ 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() : '', + priceCents: normalizedPriceCents, + kind: 'points', + pointsAmount: normalizedPointsAmount, + badgeLabel: badgeLabel.trim(), description: description.trim(), - tier: kind === 'membership' ? tier : 'normal', - membershipPeriodPoints: - kind === 'membership' - ? parsePositiveInteger(membershipPeriodPoints) - : 0, - membershipPeriodDays: - kind === 'membership' - ? parsePositiveInteger(membershipPeriodDays) - : 0, - membershipQueueLimit: - kind === 'membership' - ? parseNonNegativeInteger(membershipQueueLimit) - : 0, - membershipDiscountBps: - kind === 'membership' - ? parseNonNegativeInteger(membershipDiscountBps) - : 0, enabled, sortOrder: parseInteger(sortOrder), }); @@ -177,26 +169,29 @@ export function AdminRechargeProductPage({ 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); - setMembershipPeriodPoints(String(entry.membershipPeriodPoints)); - setMembershipPeriodDays(String(entry.membershipPeriodDays)); - setMembershipQueueLimit(String(entry.membershipQueueLimit)); - setMembershipDiscountBps(String(entry.membershipDiscountBps)); setEnabled(entry.enabled); setSortOrder(String(entry.sortOrder)); } + function resetForm() { + setProductId(DEFAULT_RECHARGE_PRODUCT_FORM.productId); + setTitle(DEFAULT_RECHARGE_PRODUCT_FORM.title); + setPriceCents(DEFAULT_RECHARGE_PRODUCT_FORM.priceCents); + setPointsAmount(DEFAULT_RECHARGE_PRODUCT_FORM.pointsAmount); + setBadgeLabel(DEFAULT_RECHARGE_PRODUCT_FORM.badgeLabel); + setDescription(DEFAULT_RECHARGE_PRODUCT_FORM.description); + setEnabled(DEFAULT_RECHARGE_PRODUCT_FORM.enabled); + setSortOrder(DEFAULT_RECHARGE_PRODUCT_FORM.sortOrder); + } + return ( -
- {productKinds.map((item) => ( - - ))} -
- - {kind === 'points' ? ( - - - setPointsAmount(event.target.value)} - /> - - - setBonusPoints(event.target.value)} - /> - - - ) : ( -
- - - - - - setDurationDays(event.target.value)} - /> - - - - - - setMembershipPeriodPoints(event.target.value) - } - /> - - - - setMembershipPeriodDays(event.target.value) - } - /> - - - - - - setMembershipQueueLimit(event.target.value) - } - /> - - - - setMembershipDiscountBps(event.target.value) - } - /> - - -
- )} + + setPointsAmount(event.target.value)} + /> + setBadgeLabel(event.target.value)} /> @@ -430,12 +302,6 @@ export function AdminRechargeProductPage({ sortable: true, sortValue: (entry) => entry.title || entry.productId, }, - { - key: 'kind', - label: '类型', - sortable: true, - sortValue: (entry) => formatProductKind(entry.kind), - }, { key: 'price', label: '价格', @@ -468,8 +334,7 @@ export function AdminRechargeProductPage({ {entry.productId} - {formatProductKind(entry.kind)} - {formatPrice(entry.priceCents)} + {formatAdminCurrencyCents(entry.priceCents)} {formatProductContent(entry)} {entry.enabled ? '启用' : '停用'} @@ -489,6 +354,10 @@ export function AdminRechargeProductPage({ label: <>状态, value: <>{result.enabled ? '启用' : '停用'}, }, + { + label: <>时间, + value: <>{formatAdminDateTime(result.updatedAt)}, + }, ]} /> @@ -510,56 +379,10 @@ function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) { }); } -function formatProductKind(kind: ProfileRechargeProductKind) { - return kind === 'points' ? '泥点' : '会员'; -} - -function formatTier(tier: ProfileMembershipTier) { - if (tier === 'starter') { - return 'Starter'; - } - if (tier === 'basic') { - return 'Basic'; - } - if (tier === 'pro') { - return 'Pro'; - } - if (tier === 'ultimate') { - return 'Ultimate'; - } - 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}天 · 每${entry.membershipPeriodDays}天${entry.membershipPeriodPoints}泥点`; -} - -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; + return `${entry.pointsAmount} 泥点`; } function parseInteger(value: string) { diff --git a/apps/admin-web/src/pages/pageUtils.ts b/apps/admin-web/src/pages/pageUtils.ts index bea44fd7e..133305631 100644 --- a/apps/admin-web/src/pages/pageUtils.ts +++ b/apps/admin-web/src/pages/pageUtils.ts @@ -31,3 +31,30 @@ export function formatUnknownJson(value: unknown) { return String(value); } } + +/** 内部共用:只接受纯十进制数字串(允许前后空白),拒绝小数、科学计数法与非法 token。 */ +function parseSafeIntegerText(value: string): number | null { + const trimmed = value.trim(); + // 中文注释:只接受纯十进制数字串,避免 `Number` 把 `1.0` / `1e2` / `0x10` / `+5` 当成整数。 + if (!/^\d+$/.test(trimmed)) { + return null; + } + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** 严格解析正安全整数:空串、小数、科学计数法、非十进制、非正数与非数字都返回 `null`。 */ +export function parsePositiveInteger(value: string): number | null { + const parsed = parseSafeIntegerText(value); + return parsed !== null && parsed > 0 ? parsed : null; +} + +/** 严格解析非负安全整数:空串、小数、科学计数法、非十进制、负数与非数字都返回 `null`。 */ +export function parseNonNegativeInteger(value: string): number | null { + return parseSafeIntegerText(value); +} + +/** 落库用宽松解析:非法输入按 `0` 处理,避免后台保存被输入抖动打断。 */ +export function parsePositiveIntegerOrZero(value: string): number { + return parsePositiveInteger(value) ?? 0; +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 27edb95c7..40ac2f214 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -141,6 +141,7 @@ const allowedUncalledTauriCommands = [ // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` // 里以字面量出现,静态扫描仍按共享注册表核验。 'read_profile_recharge_center', + 'read_profile_membership_upgrade_quote', 'read_profile_wallet_ledger', 'read_profile_referral_invite_center', 'create_profile_recharge_order', @@ -1640,11 +1641,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) { ); } -if (!( - tauriConfig.build?.beforeDevCommand?.includes( - 'run ai-game-creator-shell:dev-server', - ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') -)) { +if ( + !( + tauriConfig.build?.beforeDevCommand?.includes( + 'run ai-game-creator-shell:dev-server', + ) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve') + ) +) { throw new Error( 'AI game creator shell beforeDevCommand must start the selected Vite dev server', ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 9c2caee3e..07682aafc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -12,8 +12,9 @@ use serde::Serialize; use serde_json::Value; use shared_contracts::runtime::{ ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, - ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, - ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, + ProfileMembershipUpgradeQuoteResponse, ProfileRechargeCenterResponse, + ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, + RedeemProfileRewardCodeResponse, }; use std::time::Duration; use url::Url; @@ -191,6 +192,24 @@ pub(crate) async fn read_profile_recharge_center() -> Result Result { + let target_plan = bounded_business_id(&target_plan, "会员档位")?; + let snapshot = require_session()?; + let client = build_client()?; + request_json( + &client, + &snapshot, + Method::POST, + &["api", "profile", "membership", "upgrade-quote"], + Some(serde_json::json!({ "targetPlan": target_plan })), + "读取升级报价失败", + ) + .await +} + #[tauri::command] pub(crate) async fn read_profile_wallet_ledger() -> Result { let snapshot = require_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 8f80ac651..b19959983 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -597,6 +597,7 @@ pub(super) fn run() { set_chat_prompt_polish_reminder_disabled, set_client_server_selection, read_profile_recharge_center, + read_profile_membership_upgrade_quote, read_profile_wallet_ledger, read_profile_referral_invite_center, create_profile_recharge_order, diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 7e89cb64f..eb2d75db9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -6,40 +6,12 @@ use futures::StreamExt; use serde::Deserialize; use serde_json::Value; use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; -use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse}; +use shared_contracts::llm::LlmModelsResponse; use std::time::Duration; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024; -fn default_codex_agent_mode() -> String { - "codex".to_string() -} - -fn default_model_protocol() -> String { - "openai_responses".to_string() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelSummary { - id: String, - display_name: String, - #[serde(default = "default_codex_agent_mode")] - agent_mode: String, - /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 - #[serde(default = "default_model_protocol")] - protocol: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelCatalog { - default_model_id: String, - models: Vec, - revision: u64, -} - #[derive(Debug, Deserialize)] struct ApiErrorPayload { code: Option, @@ -49,27 +21,12 @@ struct ApiErrorPayload { #[derive(Debug, Deserialize)] struct ApiEnvelope { ok: bool, - data: Option, + /// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels` + /// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。 + data: Option, error: Option, } -fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .map(|model| LlmModelSummary { - id: model.id, - display_name: model.display_name, - agent_mode: model.agent_mode, - protocol: model.protocol, - }) - .collect(), - revision: catalog.revision, - } -} - fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; @@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { } return envelope .data - .map(map_catalog) .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); } - serde_json::from_value::(value) - .map(map_catalog) + serde_json::from_value::(value) .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) } @@ -293,6 +248,74 @@ mod tests { .contains("x-genarrative-response-envelope: v1")); } + #[test] + fn catalog_maps_unknown_unavailable_reason_to_unknown() { + let body = serde_json::json!({ + "ok": true, + "data": { + "defaultModelId": "quality", + "models": [{ + "id": "quality", + "displayName": "高质量", + "agentMode": "codex", + "protocol": "openai_responses" + }], + "unavailableModels": [{ + "id": "pro", + "displayName": "专业版", + "agentMode": "cc", + "protocol": "anthropic", + "reason": "some_future_reason" + }], + "revision": 8 + }, + "error": null + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse"); + assert_eq!(catalog.models.len(), 1); + assert_eq!(catalog.unavailable_models.len(), 1); + assert_eq!( + catalog.unavailable_models[0].reason, + shared_contracts::llm::LlmModelUnavailableReason::Unknown + ); + assert_eq!( + catalog.unavailable_models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Cc + ); + assert_eq!( + catalog.unavailable_models[0].protocol, + shared_contracts::llm::AgcModelProtocol::Anthropic + ); + } + + #[test] + fn legacy_raw_catalog_payload_defaults_optional_fields() { + // 旧 dev 代理 / 本地 fixture 直接返回裸目录(没有 `ok` 信封),且缺少 + // `agentMode` / `protocol` / `unavailableModels`:必须仍能解析,并按 Codex + + // OpenAI Responses + 空不可用桶兼容。 + let body = serde_json::json!({ + "defaultModelId": "quality", + "models": [{ "id": "quality", "displayName": "高质量" }], + "revision": 9 + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("raw catalog should parse"); + assert_eq!(catalog.default_model_id, "quality"); + assert_eq!(catalog.revision, 9); + assert!(catalog.unavailable_models.is_empty()); + assert_eq!( + catalog.models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Codex + ); + assert_eq!( + catalog.models[0].protocol, + shared_contracts::llm::AgcModelProtocol::OpenAiResponses + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 3fc145c7f..6e24c6ef7 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -11,7 +11,7 @@ import type { HomeCreationType, HomeDraft } from '../view/home'; export type RechargeContent = Omit< ProfileRechargeCenterResponse, - 'walletBalance' | 'mudPointBalance' + 'mudPointBalance' >; export type AgentChatInteractionMode = 'run' | 'chat' | 'goal'; export type AgentBackgroundSubmitMode = 'steer' | 'queue'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index 6545cd169..ecc54f70c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -42,9 +42,18 @@ export function AccountWalletDialogs({ error={controller.rechargeError} submittingProductId={controller.submittingRechargeProductId} nativePayment={controller.nativeRechargePayment} + membershipQuote={controller.membershipQuote} + isLoadingMembershipQuote={controller.membershipQuoteLoading} + membershipQuoteError={controller.membershipQuoteError} onClose={controller.closeRecharge} onRetry={() => void controller.loadRechargeCenter()} onBuy={(product) => void controller.buyRechargeProduct(product)} + onRequestMembershipQuote={(plan) => + void controller.requestMembershipQuote(plan) + } + onSubmitMembershipCheckout={(plan, cycleKind) => + controller.submitMembershipCheckout(plan, cycleKind) + } onConfirmNativePayment={() => void controller.confirmNativeRechargePayment() } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 1d6f0a339..63d8d9d82 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -1,7 +1,11 @@ +import { resolveMembershipProductId } from '@genarrative/shared'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import type { PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { type PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeProduct, ProfileWalletLedgerResponse, @@ -10,10 +14,12 @@ import type { RechargeContent } from '../../app/types'; import { confirmClientWechatProfileRechargeOrder, createClientProfileRechargeOrder, + getClientProfileMembershipUpgradeQuote, getClientProfileRechargeCenter, getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../../services/accountHost'; +import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { @@ -26,7 +32,6 @@ export function useAccountWallet(currentUserId: string) { setWalletOwner, captureWalletBalanceSnapshot, applyWalletBalanceSnapshot, - applyLegacyWalletBalanceSnapshot, onWalletBalanceMayHaveChanged, resetWalletBalance, } = useWalletStore(); @@ -46,6 +51,12 @@ export function useAccountWallet(currentUserId: string) { useState(null); const [nativeRechargePayment, setNativeRechargePayment] = useState(null); + const [membershipQuote, setMembershipQuote] = + useState(null); + const [membershipQuoteLoading, setMembershipQuoteLoading] = useState(false); + const [membershipQuoteError, setMembershipQuoteError] = useState< + string | null + >(null); const [redeemCodeOpen, setRedeemCodeOpen] = useState(false); const [redeemCodeInput, setRedeemCodeInput] = useState(''); const [redeemCodeLoading, setRedeemCodeLoading] = useState(false); @@ -54,6 +65,7 @@ export function useAccountWallet(currentUserId: string) { null, ); const rechargeLifecycleRef = useRef(0); + const membershipQuoteRequestRef = useRef(null); const walletLedgerLifecycleRef = useRef(0); const redeemLifecycleRef = useRef(0); const [walletUiOwnerUserId, setWalletUiOwnerUserId] = useState(currentUserId); @@ -76,8 +88,6 @@ export function useAccountWallet(currentUserId: string) { const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser ? mudPointBalanceError : ''; - const visibleWalletBalance = - visibleMudPointBalance?.totalPoints ?? visibleLegacyWalletBalance; useLayoutEffect(() => { currentUserIdRef.current = currentUserId; @@ -114,6 +124,9 @@ export function useAccountWallet(currentUserId: string) { setRechargeError(null); setSubmittingRechargeProductId(null); setNativeRechargePayment(null); + setMembershipQuote(null); + setMembershipQuoteLoading(false); + setMembershipQuoteError(null); redeemLifecycleRef.current += 1; setRedeemCodeOpen(false); setRedeemCodeInput(''); @@ -165,16 +178,9 @@ export function useAccountWallet(currentUserId: string) { walletSnapshot: ReturnType, center: ProfileRechargeCenterResponse, ) { - const { - walletBalance, - mudPointBalance: nextMudPointBalance, - ...content - } = center; + const { mudPointBalance: nextMudPointBalance, ...content } = center; if (nextMudPointBalance && walletSnapshot) { applyWalletBalanceSnapshot(walletSnapshot, nextMudPointBalance); - } else if (walletSnapshot && Number.isFinite(walletBalance)) { - applyLegacyWalletBalanceSnapshot(walletSnapshot, walletBalance); - void onWalletBalanceMayHaveChanged(); } else { void onWalletBalanceMayHaveChanged(); } @@ -227,6 +233,9 @@ export function useAccountWallet(currentUserId: string) { setRechargeOpen(false); setNativeRechargePayment(null); setSubmittingRechargeProductId(null); + setMembershipQuote(null); + setMembershipQuoteLoading(false); + setMembershipQuoteError(null); } function openRedeemCode() { @@ -278,7 +287,9 @@ export function useAccountWallet(currentUserId: string) { } } - async function buyRechargeProduct(product: ProfileRechargeProduct) { + async function buyRechargeProduct( + product: Pick, + ) { if (submittingRechargeProductId) { return; } @@ -353,6 +364,11 @@ export function useAccountWallet(currentUserId: string) { applyRechargeContent(walletSnapshot, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); + // 会员到账会改变账号可用的模型档位,但目录 revision 不变;显式通知 + // ConversationModelSelect 重置已应用 revision 并重新读取,否则锁定列表保持陈旧。 + if (response.order.kind === 'membership') { + notifyLlmConfigChanged(); + } void onWalletBalanceMayHaveChanged(); return; } @@ -385,6 +401,48 @@ export function useAccountWallet(currentUserId: string) { } } + async function requestMembershipQuote(plan: ProfileMembershipPlan) { + const rechargeLifecycle = rechargeLifecycleRef.current; + const snapshotOwnerUserId = currentUserId; + const requestToken = Symbol('profile-membership-quote'); + membershipQuoteRequestRef.current = requestToken; + const isLatestQuoteRequest = () => + rechargeLifecycleRef.current === rechargeLifecycle && + currentUserIdRef.current === snapshotOwnerUserId && + membershipQuoteRequestRef.current === requestToken; + setMembershipQuote(null); + setMembershipQuoteError(null); + setMembershipQuoteLoading(true); + try { + const quote = await getClientProfileMembershipUpgradeQuote(plan); + if (!isLatestQuoteRequest()) { + return; + } + setMembershipQuote(quote); + } catch (error) { + if (isLatestQuoteRequest()) { + setMembershipQuoteError( + error instanceof Error ? error.message : '读取升级报价失败', + ); + } + } finally { + if (isLatestQuoteRequest()) { + setMembershipQuoteLoading(false); + } + } + } + + function submitMembershipCheckout( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) { + // 会员商品没有商品配置行:标题与价格由后端按档位与周期现算, + // 这里只需要把 productId 交给同一条下单链路。 + void buyRechargeProduct({ + productId: resolveMembershipProductId(plan, cycleKind), + }); + } + return { ownerUserId, mudPointBalance: visibleMudPointBalance, @@ -400,13 +458,10 @@ export function useAccountWallet(currentUserId: string) { walletLedgerError: walletUiIsVisible ? walletLedgerError : null, rechargeOpen: walletUiIsVisible && rechargeOpen, rechargeModalCenter: - rechargeContent && visibleWalletBalance !== null + rechargeContent && visibleMudPointBalance !== null ? { ...rechargeContent, - walletBalance: visibleWalletBalance, - ...(visibleMudPointBalance - ? { mudPointBalance: visibleMudPointBalance } - : {}), + mudPointBalance: visibleMudPointBalance, } : null, rechargeLoading: walletUiIsVisible && rechargeLoading, @@ -415,6 +470,11 @@ export function useAccountWallet(currentUserId: string) { ? submittingRechargeProductId : null, nativeRechargePayment: walletUiIsVisible ? nativeRechargePayment : null, + membershipQuote: walletUiIsVisible ? membershipQuote : null, + membershipQuoteLoading: walletUiIsVisible && membershipQuoteLoading, + membershipQuoteError: walletUiIsVisible ? membershipQuoteError : null, + requestMembershipQuote, + submitMembershipCheckout, setNativeRechargePayment, loadWalletLedger, openWalletLedger, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx new file mode 100644 index 000000000..f2b502107 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -0,0 +1,127 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + catalog: { + defaultModelId: 'basic-model', + models: [ + { + id: 'basic-model', + displayName: '基础模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + }, + ], + unavailableModels: [ + { + id: 'pro-model', + displayName: '专业模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + reason: 'plan_required' as const, + }, + { + id: 'retired-model', + displayName: '下线模型', + agentMode: 'cc' as const, + protocol: 'anthropic' as const, + reason: 'disabled' as const, + }, + ], + revision: 1, + }, +})); + +vi.mock('../../app/tauri', () => ({ + resolveTauriInvoke: () => mocks.invoke, +})); + +vi.mock('../../services/llmModelCatalog', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + cachedLlmModelCatalog: () => mocks.catalog, + refreshLlmModelCatalog: () => Promise.resolve(mocks.catalog), + }; +}); + +import { ConversationModelSelect } from './ConversationModelSelect'; + +function mockConfig(selectedModelId: string, isDefault: boolean) { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + config: { + selectedModelId, + selectedModelIsDefault: isDefault, + }, + }; + } + if (command === 'select_game_creator_model') { + return { + config: { + selectedModelId: 'basic-model', + selectedModelIsDefault: true, + }, + }; + } + return {}; + }); +} + +afterEach(() => { + cleanup(); + mocks.invoke.mockReset(); +}); + +describe('ConversationModelSelect 锁定模型', () => { + it('不可用模型渲染为不可点、可悬停查看原因且不进入 Tab 序列', async () => { + mockConfig('basic-model', true); + render(); + // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const planLocked = screen.getByText('专业模型').closest('[role="option"]'); + expect(planLocked?.tagName).toBe('DIV'); + expect(planLocked?.getAttribute('aria-disabled')).toBe('true'); + // 非焦点项:不进入 Tab 序列,避免键盘在不可选项上停留。 + expect(planLocked?.getAttribute('tabindex')).toBeNull(); + expect(planLocked?.getAttribute('data-reason')).toBe('plan_required'); + expect(planLocked?.getAttribute('aria-label')).toBe( + '专业模型:订阅计划不支持', + ); + expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持'); + + const disabledLocked = screen + .getByText('下线模型') + .closest('[role="option"]'); + expect(disabledLocked?.getAttribute('data-tooltip')).toBe('该模型已下线'); + }); + + it('已选模型落进不可用桶时按 reason 回退默认并提示', async () => { + mockConfig('retired-model', false); + render(); + + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe( + '所选模型已下线,已切换为默认模型', + ), + ); + expect(mocks.invoke).toHaveBeenCalledWith( + 'select_game_creator_model', + expect.objectContaining({ modelId: 'basic-model', isDefault: true }), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..040474bf1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; +import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react'; import type { Ref } from 'react'; import { useCallback, @@ -14,10 +14,15 @@ import { cachedLlmModelCatalog, type ClientLlmModel, type ClientLlmModelCatalog, + type ClientLlmUnavailableModel, LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +import { + unavailableModelFallbackNotice, + unavailableModelReasonLabel, +} from './modelAvailabilityCopy'; /** * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 @@ -76,6 +81,9 @@ export function ConversationModelSelect({ const [models, setModels] = useState( initialCatalog?.models ?? [], ); + const [unavailableModels, setUnavailableModels] = useState< + ClientLlmUnavailableModel[] + >(initialCatalog?.unavailableModels ?? []); const [selected, setSelected] = useState(''); const [defaultModelId, setDefaultModelId] = useState( initialCatalog?.defaultModelId ?? '', @@ -136,6 +144,9 @@ export function ConversationModelSelect({ showBusy: boolean, epochAtRequest: number, ) => { + // 目录契约的 `unavailableModels` 是加法字段:旧缓存 / 局部数据可能缺失。 + // 统一归一化一次,避免下面 setState 与按 reason 取文案时解引用 undefined。 + const catalogUnavailableModels = catalog.unavailableModels ?? []; if ( mountedRef.current && (appliedRevisionRef.current !== catalog.revision || @@ -143,6 +154,7 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setUnavailableModels(catalogUnavailableModels); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); @@ -178,7 +190,13 @@ export function ConversationModelSelect({ if (!next && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; - if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + if (saved) { + // 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。 + nextNotice = unavailableModelFallbackNotice( + catalogUnavailableModels.find((model) => model.id === saved) + ?.reason, + ); + } } if (next && (next !== saved || nextIsDefault !== followsDefault)) { let persisted: GameCreatorAppConfigView; @@ -253,6 +271,7 @@ export function ConversationModelSelect({ appliedRevisionRef.current = null; selectedRef.current = ''; setModels([]); + setUnavailableModels([]); setSelected(''); setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); @@ -315,6 +334,7 @@ export function ConversationModelSelect({ selectedRef.current = ''; setSelected(''); setModels([]); + setUnavailableModels([]); void syncCatalog(true); } window.addEventListener('focus', handleWindowFocus); @@ -455,6 +475,28 @@ export function ConversationModelSelect({ ) : null} ))} + {/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露, + 且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */} + {unavailableModels.map((model) => { + const reasonLabel = unavailableModelReasonLabel(model.reason); + return ( +
+ + {model.displayName} + +
+ ); + })}
- 充值 + 会员与泥点 {onRedeemCode ? ( <> @@ -249,13 +276,13 @@ export function PlatformMudPointWalletEntry({ {isOpen ? (
- 泥点 {exactBalanceLabel} + 可用总额 {exactBalanceLabel}
{breakdown ? ( <> - {/*TODO 限时泥点 is not enabled by now */} + {shouldShowProfileDailyFreePool(breakdown) ? ( + + ) : null} ) : (
diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index fe2ff64d1..86cc808b0 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -11,7 +11,7 @@ import { PlatformProfileRechargeModal } from './index'; function buildNormalMembership() { return { status: 'normal' as const, - tier: 'normal' as const, + plan: 'normal' as const, startedAt: null, expiresAt: null, updatedAt: null, @@ -19,7 +19,41 @@ function buildNormalMembership() { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 30, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly' as const, + }; +} + +function buildActiveMembership( + plan: 'plus' | 'max', + cycleKind: 'monthly' | 'yearly', +) { + return { + status: 'active' as const, + plan, + startedAt: '2026-01-01T00:00:00+08:00', + expiresAt: '2027-01-01T00:00:00+08:00', + updatedAt: '2026-04-01T00:00:00+08:00', + cycleStartedAt: '2026-03-20T09:00:00+08:00', + cycleResetsAt: '2026-04-20T09:00:00+08:00', + cycleGrantedPoints: 1_150, + cycleRemainingPoints: 615, + cycleIndex: 4, + cycleCount: 12, + cycleKind, + }; +} + +/** 三池余额 fixture:每日免费 / 月度 / 永久。 */ +function buildMudPointBalance(totalPoints: number) { + return { + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + monthlyPoints: 0, + monthlyResetsAt: null, + permanentPoints: totalPoints, }; } @@ -36,62 +70,62 @@ function buildPointProduct( durationDays: 0, badgeLabel: '', description: '60泥点', - tier: 'normal' as const, - membershipPeriodPoints: 0, - membershipPeriodDays: 0, - membershipQueueLimit: 0, - membershipDiscountBps: 0, ...overrides, }; } +// 六档充点商品:没有首充赠送。 function buildPointProducts(): ProfileRechargeProduct[] { - return [ - buildPointProduct(), + return [60, 180, 300, 680, 1_280, 3_280].map((pointsAmount) => buildPointProduct({ - productId: 'points_180', - title: '180泥点', - priceCents: 1800, - pointsAmount: 180, - bonusPoints: 90, - badgeLabel: '首充加赠', - description: '首充加赠90泥点', + productId: `points_${pointsAmount}`, + title: `${pointsAmount}泥点`, + priceCents: pointsAmount * 10, + pointsAmount, + description: `${pointsAmount}泥点`, }), - buildPointProduct({ - productId: 'points_300', - title: '300泥点', - priceCents: 3000, - pointsAmount: 300, - bonusPoints: 150, - badgeLabel: '首充加赠', - description: '首充加赠150泥点', - }), - buildPointProduct({ - productId: 'points_680', - title: '680泥点', - priceCents: 6800, - pointsAmount: 680, - bonusPoints: 340, - badgeLabel: '首充加赠', - description: '首充加赠340泥点', - }), - ]; + ); } -function buildMembershipProduct(): ProfileRechargeProduct { - return { - ...buildPointProduct(), - productId: 'member_month', - title: '会员月卡', - priceCents: 2800, - kind: 'membership', - pointsAmount: 0, - durationDays: 30, - description: '会员月卡', - tier: 'month', - membershipPeriodDays: 30, - }; -} +const STARTER_PLAN = { + plan: 'starter' as const, + title: 'Starter', + rank: 1, + monthPriceCents: 3_900, + yearPriceCents: 39_000, + periodPoints: 400, + modelAccess: 'basic' as const, + concurrentJobLimit: 1, + recommended: false, + unlimitedConcurrency: false, + enabled: true, +}; + +const PLUS_PLAN = { + ...STARTER_PLAN, + plan: 'plus' as const, + title: 'Plus', + rank: 2, + monthPriceCents: 9_900, + yearPriceCents: 99_000, + periodPoints: 1_150, + modelAccess: 'full' as const, + concurrentJobLimit: 3, + recommended: true, +}; + +const MAX_PLAN = { + ...STARTER_PLAN, + plan: 'max' as const, + title: 'Max', + rank: 4, + monthPriceCents: 69_900, + yearPriceCents: 699_000, + periodPoints: 8_650, + modelAccess: 'full' as const, + concurrentJobLimit: 128, + unlimitedConcurrency: true, +}; vi.mock('qrcode', () => ({ default: { @@ -100,18 +134,311 @@ vi.mock('qrcode', () => ({ })); describe('PlatformProfileRechargeModal', () => { - test('renders the four point products and forwards the selected buy action', async () => { + test('renders the membership plans and forwards a first-time purchase', async () => { + const user = userEvent.setup(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect(screen.getByRole('dialog', { name: '会员与泥点' })).toBeTruthy(); + expect(screen.getByText('可用总额 29 泥点')).toBeTruthy(); + const tablist = screen.getByRole('tablist', { name: '会员与泥点' }); + expect( + within(tablist) + .getByRole('tab', { name: '会员订阅' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); + expect(screen.getAllByText('Plus').length).toBeGreaterThan(0); + + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('¥39')).toBeTruthy(); + expect(within(checkout).getByText('400(首期)')).toBeTruthy(); + expect(within(checkout).getByText('微信支付')).toBeTruthy(); + expect(within(checkout).queryByText(/报价已失效/)).toBeNull(); + expect(within(checkout).queryByText(/倒计时/)).toBeNull(); + + await user.click( + within(checkout).getByRole('button', { name: '支付 ¥39' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith( + 'starter', + 'monthly', + ); + }); + + test('周期切换按钮支持方向键与 roving tabindex', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const group = screen.getByRole('radiogroup', { name: '付费周期' }); + const monthly = within(group).getByRole('radio', { name: '月付' }); + const yearly = within(group).getByRole('radio', { name: '年付' }); + expect(monthly.getAttribute('tabindex')).toBe('0'); + expect(yearly.getAttribute('tabindex')).toBe('-1'); + + monthly.focus(); + await user.keyboard('{ArrowRight}'); + + expect(yearly.getAttribute('aria-checked')).toBe('true'); + expect(monthly.getAttribute('aria-checked')).toBe('false'); + expect(yearly.getAttribute('tabindex')).toBe('0'); + expect(monthly.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(yearly); + }); + + test('requests the server quote and shows the upgrade amount', async () => { + const user = userEvent.setup(); + const onRequestMembershipQuote = vi.fn(); + const onSubmitMembershipCheckout = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole('button', { name: 'Plus 年付 当前套餐' }), + ).toHaveProperty('disabled', true); + expect(screen.getByText('当前 Plus · 年付')).toBeTruthy(); + expect(screen.getAllByText(/可同周期补差升档/).length).toBeGreaterThan(0); + + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + + expect(onRequestMembershipQuote).toHaveBeenCalledWith('max'); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + expect(within(checkout).getByText('¥100')).toBeTruthy(); + expect(within(checkout).getByText('本期补发月度泥点')).toBeTruthy(); + + await user.click( + within(checkout).getByRole('button', { name: '补差升级 ¥100' }), + ); + + expect(onSubmitMembershipCheckout).toHaveBeenCalledWith('max', 'yearly'); + }); + + test('已是最高的 max 档不再提示可补差升档', () => { + render( + , + ); + + expect(screen.getByText('当前 Max · 年付')).toBeTruthy(); + expect(screen.queryByText(/可同周期补差升档/)).toBeNull(); + expect(screen.getByText('周期切换需等到期后')).toBeTruthy(); + }); + + test('keeps an active member as member when their current plan is disabled', async () => { + const user = userEvent.setup(); + const onRequestMembershipQuote = vi.fn(); + + render( + , + ); + + // 状态卡仍认得当前档位,而不是退化成「会员」。 + expect(screen.getByText('当前 Plus · 年付')).toBeTruthy(); + // 有效会员看到的是补差升级,而不是可在有效会员上重复开通的「开通会员」。 + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + expect(onRequestMembershipQuote).toHaveBeenCalledWith('max'); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + // 即使原档位已禁用,升级摘要也要给出「原套餐标准年价」。 + expect(within(checkout).getByText('原套餐标准年价')).toBeTruthy(); + expect(within(checkout).getByText('¥990')).toBeTruthy(); + }); + + test('shows a placeholder instead of the list price while the upgrade quote is loading', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Max 年付 补差升级' })); + + const checkout = screen.getByRole('dialog', { name: '确认套餐变更' }); + // 报价未到:金额占位,不能把 Max 标准年价(¥6990)当成「本次补付」。 + expect(within(checkout).getByText('—')).toBeTruthy(); + expect( + within(checkout).queryByRole('button', { name: /^补差升级 ¥/ }), + ).toBeNull(); + expect( + within(checkout).getByRole('button', { name: '补差升级' }), + ).toHaveProperty('disabled', true); + }); + + test('recharge tab picks a point product and forwards the buy action', async () => { const user = userEvent.setup(); const onBuy = vi.fn(); render( { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={onBuy} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); - const dialog = screen.getByRole('dialog', { name: '购买更多泥点' }); - expect(within(dialog).getByText('当前余额 29 泥点')).toBeTruthy(); - expect( - within(dialog).getByRole('button', { name: '60泥点 ¥6 购买' }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '180泥点 +90泥点 ¥18 购买', - }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '300泥点 +150泥点 ¥30 购买', - }), - ).toBeTruthy(); - expect( - within(dialog).getByRole('button', { - name: '680泥点 +340泥点 ¥68 购买', - }), - ).toBeTruthy(); - expect(within(dialog).getAllByText('首充加赠')).toHaveLength(3); - expect(within(dialog).queryByRole('tablist')).toBeNull(); - expect(within(dialog).queryByText('会员月卡')).toBeNull(); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + const products = screen.getByRole('radiogroup', { name: '泥点充值档位' }); await user.click( - within(dialog).getByRole('button', { - name: '180泥点 +90泥点 ¥18 购买', - }), - ); - expect(onBuy).toHaveBeenCalledWith( - expect.objectContaining({ productId: 'points_180' }), + within(products).getByRole('radio', { name: '60泥点 ¥6' }), ); + + await user.click(screen.getByRole('button', { name: '充值 ¥6' })); + + const checkout = screen.getByRole('dialog', { name: '确认订单' }); + expect(within(checkout).getByText('60 永久泥点')).toBeTruthy(); + + await user.click(within(checkout).getByRole('button', { name: '充值 ¥6' })); + + expect(onBuy).toHaveBeenCalledTimes(1); + expect(onBuy.mock.calls[0][0]).toMatchObject({ + productId: 'points_60', + priceCents: 600, + }); }); - test('shows the point-product empty state without exposing membership purchase', () => { + test('会员订单提交中时泥点档位仍可选,但下单入口禁用', async () => { + const user = userEvent.setup(); + const renderModal = (submittingProductId: string | null) => ( + + ); + + const { rerender } = render(renderModal(null)); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + const product = within( + screen.getByRole('radiogroup', { name: '泥点充值档位' }), + ).getByRole('radio', { name: '60泥点 ¥6' }); + // 既有行为:会员订单提交不波及泥点档位的可选状态。 + expect(product).not.toHaveProperty('disabled', true); + + await user.click(product); + await user.click(screen.getByRole('button', { name: '充值 ¥6' })); + + // 会员订单提交中:泥点结算 CTA 禁用,避免被宿主守卫静默丢弃。 + rerender(renderModal('membership-pro-monthly')); + expect( + within(screen.getByRole('dialog', { name: '确认订单' })).getByRole( + 'button', + { name: '充值 ¥6' }, + ), + ).toHaveProperty('disabled', true); + }); + + test('泥点订单提交中时会员结算入口禁用', async () => { + const user = userEvent.setup(); + const renderModal = (submittingProductId: string | null) => ( + + ); + + const { rerender } = render(renderModal(null)); + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + // 泥点订单提交中:会员结算 CTA 同样禁用,避免宿主守卫静默丢弃提交后直接关弹层。 + rerender(renderModal('points_60')); + expect( + within(screen.getByRole('dialog', { name: '确认订单' })).getByRole( + 'button', + { name: '支付 ¥39' }, + ), + ).toHaveProperty('disabled', true); + }); + + test('shows the empty state when the recharge tab has no point product', async () => { + const user = userEvent.setup(); + render( { error={null} submittingProductId={null} nativePayment={null} + membershipQuote={null} + isLoadingMembershipQuote={false} + membershipQuoteError={null} onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={vi.fn()} onCloseNativePayment={vi.fn()} />, ); + await user.click(screen.getByRole('tab', { name: '泥点充值' })); + + expect(screen.getAllByText('Starter').length).toBeGreaterThan(0); expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); - expect(screen.queryByText('会员月卡')).toBeNull(); - expect(screen.queryByRole('tablist')).toBeNull(); }); test('opens native payment QR code in a separate dialog', async () => { @@ -198,17 +618,19 @@ describe('PlatformProfileRechargeModal', () => { render( { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={onCloseNativePayment} />, ); const rechargeDialog = screen.getByRole('dialog', { - name: '购买更多泥点', + name: '会员与泥点', }); const paymentDialog = screen.getByRole('dialog', { name: '微信扫码支付', @@ -266,17 +690,19 @@ describe('PlatformProfileRechargeModal', () => { render( { onClose={vi.fn()} onRetry={vi.fn()} onBuy={vi.fn()} + onRequestMembershipQuote={vi.fn()} + onSubmitMembershipCheckout={vi.fn()} onConfirmNativePayment={onConfirmNativePayment} onCloseNativePayment={vi.fn()} />, @@ -307,4 +735,74 @@ describe('PlatformProfileRechargeModal', () => { expect(confirmButton).toHaveProperty('disabled', true); expect(onConfirmNativePayment).not.toHaveBeenCalled(); }); + + test('hides the daily free pool when the daily grant is zero', () => { + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '会员与泥点' }); + expect(within(dialog).queryByText('每日免费')).toBeNull(); + expect(within(dialog).getByText('月度泥点')).toBeTruthy(); + expect(within(dialog).getByText('永久泥点')).toBeTruthy(); + }); + + test('shows the daily free pool while the daily grant is positive', () => { + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '会员与泥点' }); + expect(within(dialog).getByText('每日免费')).toBeTruthy(); + }); }); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index ecec85c75..25764bb1c 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -2,16 +2,71 @@ import './index.css'; import QRCode from 'qrcode'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useId, useState } from 'react'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import type { + ProfileMembership, + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipPlanRecord, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeProduct, } from '../../contracts/runtime'; import { formatMudPointCount, formatRechargePrice } from '../../utils/format'; +import { + formatMudPointResetAt, + resolveProfileMudPointDisplayTotal, + shouldShowProfileDailyFreePool, +} from '../../utils/mudPoints'; +import { + resolveMembershipConcurrencyLabel, + resolveMembershipConcurrencyShortLabel, + resolveMembershipModelAccessLabel, + resolveMembershipPlanCopy, +} from './membershipCopy'; +import { resolveMembershipProductId } from './membershipProduct'; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; +/** + * roving tabindex / radiogroup 的方向键索引解析:`Home` / `End` 跳首尾,左右键循环, + * 其余按键返回 `null`(调用方据此忽略事件)。`currentIndex` 允许为 `-1`(未选中)。 + */ +function resolveRovingIndex( + key: string, + currentIndex: number, + length: number, +): number | null { + if (length <= 0) { + return null; + } + switch (key) { + case 'Home': + return 0; + case 'End': + return length - 1; + case 'ArrowLeft': + // 未选中(-1)时应回卷到最后一项,而不是 (length - 2) % length。 + return currentIndex < 0 + ? length - 1 + : (currentIndex - 1 + length) % length; + case 'ArrowRight': + return (currentIndex + 1) % length; + default: + return null; + } +} + +type PlatformProfileRechargeTab = 'subscription' | 'recharge'; + export type PlatformProfileRechargeNativePaymentState = { orderId: string; productTitle: string; @@ -22,15 +77,40 @@ export type PlatformProfileRechargeNativePaymentState = { confirmMessage?: string; }; +/** 弹层内的结算目标:泥点商品直接带商品行,会员只有「档位 + 周期」。 */ +export type PlatformProfileRechargeCheckoutTarget = + | { + kind: 'points'; + product: ProfileRechargeProduct; + } + | { + kind: 'membership'; + plan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + mode: 'purchase' | 'upgrade'; + }; + export type PlatformProfileRechargeModalProps = { center: ProfileRechargeCenterResponse | null; isLoading: boolean; error: string | null; submittingProductId: string | null; nativePayment: PlatformProfileRechargeNativePaymentState | null; + /** 升级报价;仅升级确认页会用到,首次购买不走报价接口。 */ + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; onClose: () => void; onRetry: () => void; + /** 泥点商品下单(确认页「充值」触发)。 */ onBuy: (product: ProfileRechargeProduct) => void; + /** 升级前取服务端报价;只有需要补差的档位才会调用。 */ + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + /** 会员下单;productId 由档位与周期拼出,复用泥点下单接口。 */ + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; onConfirmNativePayment: () => void; onCloseNativePayment: () => void; }; @@ -127,16 +207,19 @@ function PlatformRechargeActionButton({ children, className, disabled, + ariaLabel, onClick, }: { children: ReactNode; className?: string; disabled?: boolean; + ariaLabel?: string; onClick?: () => void; }) { return ( +
+ {pools.map((pool) => ( +
+
+ {pool.label} +
+
+ {formatMudPointCount(pool.points)} +
+
+ {pool.detail} +
+
+ ))} +
+ {showDailyFree && dailyFreeResetsAt ? ( +

+ 创作扣点顺序:每日免费 → 月度 → 永久,{dailyFreeResetsAt}{' '} + 重置每日免费额度。 +

+ ) : null} +
+ ); +} + +function PlatformRechargeTabNav({ + activeTab, + onChange, +}: { + activeTab: PlatformProfileRechargeTab; + onChange: (tab: PlatformProfileRechargeTab) => void; +}) { + const tabs: Array<{ id: PlatformProfileRechargeTab; label: string }> = [ + { id: 'subscription', label: '会员订阅' }, + { id: 'recharge', label: '泥点充值' }, + ]; + // 方向键移动选中项后把焦点一并移到新页签,保持 roving tabindex 的焦点与选中一致。 + const tabRefs = useRef>({}); + + return ( +
{ + const currentIndex = tabs.findIndex((tab) => tab.id === activeTab); + const nextIndex = resolveRovingIndex( + event.key, + currentIndex, + tabs.length, + ); + if (nextIndex === null) { + return; + } + event.preventDefault(); + const nextTab = tabs[nextIndex]; + if (nextTab) { + onChange(nextTab.id); + tabRefs.current[nextTab.id]?.focus(); + } + }} + > + {tabs.map((tab) => { + const selected = tab.id === activeTab; + return ( + + ); + })} +
+ ); +} + +function PlatformMembershipStatusCard({ + membership, + currentPlan, + hasUpgradePath, +}: { + membership: ProfileMembership; + currentPlan: ProfileMembershipPlanRecord | null; + /** 是否存在更高档位可补差升级;已是最高的 max 档时为 false。 */ + hasUpgradePath: boolean; +}) { + const planTitle = currentPlan?.title ?? '会员'; + const cycleLabel = membership.cycleKind === 'yearly' ? '年付' : '月付'; + const expiresAt = formatMudPointResetAt(membership.expiresAt); + const cycleIndexLabel = `${Math.max(1, membership.cycleIndex)}/${Math.max( + 1, + membership.cycleCount, + )}`; + + return ( +
+
+
+ 当前 {planTitle} · {cycleLabel} +
+
+ {expiresAt ? `会员至 ${expiresAt}` : '会员有效期读取中'} + {membership.cycleKind === 'yearly' + ? ` · 第 ${cycleIndexLabel} 期` + : ''} +
+
+
+ {hasUpgradePath ? ( + <> + 可同周期补差升档 +
+ 周期切换需等到期后 + + ) : ( + '周期切换需等到期后' + )} +
+
+ ); +} + +function PlatformMembershipCycleSwitch({ + cycleKind, + locked, + onChange, +}: { + cycleKind: ProfileMembershipCycleKind; + locked: boolean; + onChange: (cycleKind: ProfileMembershipCycleKind) => void; +}) { + const options: Array<{ id: ProfileMembershipCycleKind; label: string }> = [ + { id: 'monthly', label: '月付' }, + { id: 'yearly', label: '年付' }, + ]; + // 与泥点档位一致:roving tabindex + 方向键移动焦点,保证键盘可切换周期。 + const optionRefs = useRef>({}); + + return ( +
+
{ + if (locked) { + return; + } + const currentIndex = options.findIndex( + (option) => option.id === cycleKind, + ); + const nextIndex = resolveRovingIndex( + event.key, + currentIndex, + options.length, + ); + if (nextIndex === null) { + return; + } + event.preventDefault(); + const nextOption = options[nextIndex]; + if (nextOption) { + onChange(nextOption.id); + optionRefs.current[nextOption.id]?.focus(); + } + }} + > + {options.map((option) => { + const selected = option.id === cycleKind; + return ( + + ); + })} +
+ {locked ? ( + + 有效期内不支持月年互换 + + ) : null} +
+ ); +} + +function PlatformMembershipPlanCard({ + plan, + cycleKind, + isCurrentPlan, + recommended, + actionLabel, + disabled, + submitting, + onCheckout, +}: { + plan: ProfileMembershipPlanRecord; + cycleKind: ProfileMembershipCycleKind; + isCurrentPlan: boolean; + recommended: boolean; + actionLabel: string; + disabled: boolean; + submitting: boolean; + onCheckout: () => void; +}) { + const copy = resolveMembershipPlanCopy(plan.plan); + const annual = cycleKind === 'yearly'; + const listPriceCents = annual ? plan.yearPriceCents : plan.monthPriceCents; + const perMonthCents = annual + ? Math.round(plan.yearPriceCents / 12) + : plan.monthPriceCents; + const savingCents = annual + ? Math.max(0, plan.monthPriceCents * 12 - plan.yearPriceCents) + : 0; + const annualPriceLine = `年实付 ${formatRechargePrice(plan.yearPriceCents)}${ + savingCents > 0 ? ` · 省 ${formatRechargePrice(savingCents)}` : '' + }`; + // 按钮文案优先级:不可购买时展示业务文案,其次「处理中」,最后默认「选择并确认」。 + let actionButtonLabel = '选择并确认'; + if (disabled) { + actionButtonLabel = actionLabel; + } else if (submitting) { + actionButtonLabel = '处理中'; + } + + return ( +
+ {recommended ? ( + + 推荐之选 + + ) : null} +
+

+ {plan.title} +

+

+ {copy.estimateAlt} +

+
+
+ + {formatRechargePrice(perMonthCents)} + + + {annual ? '折合 / 月' : '/ 月'} + +
+
+ {annual + ? annualPriceLine + : `每次支付 ${formatRechargePrice(listPriceCents)},有效期 1 个月`} +
+
+
+ {formatMudPointCount(plan.periodPoints)} 月度泥点 +
+
+ {annual ? '每月刷新 · 全年发放 12 期' : '每月刷新 · 当期有效'} +
+
+
+ 每月预计可完成约 {copy.estimateCount} · {copy.estimateKind} +
+
    +
  • {resolveMembershipModelAccessLabel(plan.modelAccess)}
  • +
  • {resolveMembershipConcurrencyLabel(plan)}
  • +
+ + {actionButtonLabel} + +
+ ); +} + +function PlatformMembershipComparisonTable({ + plans, +}: { + plans: ProfileMembershipPlanRecord[]; +}) { + const rows: Array<{ + label: string; + values: string[]; + }> = [ + { + label: '月付价格', + values: plans.map((plan) => formatRechargePrice(plan.monthPriceCents)), + }, + { + label: '年付价格', + values: plans.map((plan) => formatRechargePrice(plan.yearPriceCents)), + }, + { + label: '每期泥点', + values: plans.map((plan) => formatMudPointCount(plan.periodPoints)), + }, + { + label: '模型权限', + values: plans.map((plan) => + resolveMembershipModelAccessLabel(plan.modelAccess), + ), + }, + { + label: '独立任务并发', + values: plans.map((plan) => resolveMembershipConcurrencyShortLabel(plan)), + }, + ]; + + return ( +
+ + + + + {plans.map((plan) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.values.map((value, index) => ( + + ))} + + ))} + +
+ 套餐 + + {plan.title} +
+ {row.label} + + {value} +
+
+ ); +} + +function PlatformPointProductGrid({ + products, + selectedProductId, + submittingProductId, + onSelect, +}: { + products: ProfileRechargeProduct[]; + selectedProductId: string | null; + submittingProductId: string | null; + onSelect: (product: ProfileRechargeProduct) => void; +}) { + // 方向键移动选中项后同步移动焦点,保持 roving tabindex 的焦点与选中一致。 + const productRefs = useRef>({}); + return ( +
{ + const currentIndex = products.findIndex( + (product) => product.productId === selectedProductId, + ); + const nextIndex = resolveRovingIndex( + event.key, + currentIndex, + products.length, + ); + if (nextIndex === null) { + return; + } + event.preventDefault(); + const nextProduct = products[nextIndex]; + if (nextProduct) { + onSelect(nextProduct); + productRefs.current[nextProduct.productId]?.focus(); + } + }} + > + {products.map((product) => { + const selected = product.productId === selectedProductId; + const submitting = submittingProductId === product.productId; + return ( + + ); + })} +
+ ); +} + +function PlatformRechargeCheckoutDialog({ + target, + membershipPlans, + currentPlan, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, + submittingProductId, + showDailyFreePool, + onClose, + onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, +}: { + target: PlatformProfileRechargeCheckoutTarget; + membershipPlans: ProfileMembershipPlanRecord[]; + /** 会员当前档位;来自未过滤目录,档位被后台临时禁用时仍保留「原套餐标准价」。 */ + currentPlan: ProfileMembershipPlanRecord | null; + membershipQuote: ProfileMembershipUpgradeQuote | null; + isLoadingMembershipQuote: boolean; + membershipQuoteError: string | null; + submittingProductId: string | null; + showDailyFreePool: boolean; + onClose: () => void; + onBuy: (product: ProfileRechargeProduct) => void; + onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; + onSubmitMembershipCheckout: ( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, + ) => void; +}) { + if (target.kind === 'points') { + const product = target.product; + const submitting = submittingProductId === product.productId; + return ( + { + onBuy(product); + onClose(); + }} + lines={[ + { label: '充值项目', value: product.title }, + { + label: '本次到账', + value: `${formatMudPointCount(product.pointsAmount)} 永久泥点`, + }, + ]} + note={ + showDailyFreePool + ? '永久有效,不会随会员到期清零。创作时先扣每日免费与月度泥点,不足部分再扣永久泥点。' + : '永久有效,不会随会员到期清零。创作时先扣月度泥点,不足部分再扣永久泥点。' + } + /> + ); + } + + const plan = + membershipPlans.find((item) => item.plan === target.plan) ?? null; + const annual = target.cycleKind === 'yearly'; + const cycleLabel = annual ? '年付' : '月付'; + const listPriceCents = annual + ? (plan?.yearPriceCents ?? 0) + : (plan?.monthPriceCents ?? 0); + const isUpgrade = target.mode === 'upgrade'; + const productId = resolveMembershipProductId(target.plan, target.cycleKind); + const submitting = submittingProductId === productId; + const quoteForTarget = + isUpgrade && + membershipQuote && + membershipQuote.targetPlan === target.plan && + membershipQuote.cycleKind === target.cycleKind + ? membershipQuote + : null; + // 升级金额只能来自服务端报价:报价未到 / 失败时不能拿新套餐标准价冒充「本次补付」。 + const amountPending = isUpgrade && !quoteForTarget; + const amountCents = quoteForTarget + ? quoteForTarget.amountCents + : listPriceCents; + const amountText = amountPending ? '—' : formatRechargePrice(amountCents); + let ctaLabel: string; + if (!isUpgrade) { + ctaLabel = `支付 ${amountText}`; + } else if (amountPending) { + ctaLabel = '补差升级'; + } else { + ctaLabel = `补差升级 ${amountText}`; + } + const canSubmit = + Boolean(plan) && + // 与充点分支一致:任一商品在途都禁用提交,避免静默无操作后直接关弹层。 + !submittingProductId && + (!isUpgrade || Boolean(quoteForTarget)) && + amountCents > 0; + + const lines: Array<{ label: string; value: string }> = []; + if (isUpgrade) { + if (currentPlan) { + lines.push({ + label: `原套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice( + annual ? currentPlan.yearPriceCents : currentPlan.monthPriceCents, + ), + }); + } + lines.push({ + label: `新套餐标准${annual ? '年' : '月'}价`, + value: formatRechargePrice(listPriceCents), + }); + if (quoteForTarget) { + lines.push({ + label: '本期补发月度泥点', + value: `+${formatMudPointCount(quoteForTarget.grantedPointsDelta)}`, + }); + lines.push({ + label: '补点后月度余额', + value: formatMudPointCount(quoteForTarget.monthlyBalanceAfter), + }); + const expiresAt = formatMudPointResetAt(quoteForTarget.expiresAt); + const cycleResetsAt = formatMudPointResetAt(quoteForTarget.cycleResetsAt); + if (expiresAt) { + lines.push({ label: '会员到期日(不变)', value: expiresAt }); + } + if (cycleResetsAt) { + lines.push({ label: '本期刷新日(不变)', value: cycleResetsAt }); + } + } + } else { + lines.push({ + label: '到账月度泥点', + value: plan ? `${formatMudPointCount(plan.periodPoints)}(首期)` : '—', + }); + lines.push({ + label: '计费周期', + value: annual + ? '年付 · 共 12 期,本次只到账首期' + : '月付 · 本次到账当期额度', + }); + } + + return ( + { + onSubmitMembershipCheckout(target.plan, target.cycleKind); + onClose(); + }} + lines={lines} + note={ + isUpgrade + ? '按剩余比例补差,保留现有余额,已用点不返还;升级后原刷新日与到期日不变。' + : `月度泥点按开通日自然月计期,当期未用完部分到期清零,不结转。当前选择 ${cycleLabel}。` + } + errorState={resolveMembershipQuoteErrorState({ + isUpgrade, + error: membershipQuoteError, + isLoading: isLoadingMembershipQuote, + onRetry: () => onRequestMembershipQuote(target.plan), + })} + /> + ); +} + +function PlatformRechargeCheckoutShell({ + title, + amountCents, + amountPending = false, + amountLabel = '本次支付', + ctaLabel, + ctaDisabled = false, + submitting, + lines, + note, + errorState, + onClose, + onSubmit, +}: { + title: string; + amountCents: number; + /** 金额未知(升级报价未到 / 失败)时只显示占位符,避免拿标准价冒充补差金额。 */ + amountPending?: boolean; + amountLabel?: string; + ctaLabel: string; + ctaDisabled?: boolean; + submitting: boolean; + lines: Array<{ label: string; value: string }>; + note: string; + errorState?: ReactNode; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + +
+
+ {amountLabel} +
+
+ {amountPending ? '—' : formatRechargePrice(amountCents)} +
+
+ {errorState ?
{errorState}
: null} +
+ {lines.map((line) => ( +
+ + {line.label} + + + {line.value} + +
+ ))} +
+
+ 支付方式 +
+ {/* 中文注释:当前只支持微信支付,是纯展示文案,不用 radiogroup / radio 语义(不可交互的 + 单选项会误导读屏)。 */} +
+ + + 微 + + 微信支付 + +
+ + {submitting ? '处理中' : ctaLabel} + +

+ {note} +

+
); } @@ -453,30 +1329,132 @@ export function PlatformProfileRechargeModal({ error, submittingProductId, nativePayment, + membershipQuote, + isLoadingMembershipQuote, + membershipQuoteError, onClose, onRetry, onBuy, + onRequestMembershipQuote, + onSubmitMembershipCheckout, onConfirmNativePayment, onCloseNativePayment, }: PlatformProfileRechargeModalProps) { const nativeQrImageUrl = useWechatNativeQrCode( nativePayment?.codeUrl ?? null, ); - const products = center?.pointProducts ?? []; - const currentBalance = - center?.mudPointBalance?.totalPoints ?? center?.walletBalance; + const membership = center?.membership ?? null; + const isActiveMembership = + membership?.status === 'active' && membership.plan !== 'normal'; + const currentBalance = resolveProfileMudPointDisplayTotal( + center?.mudPointBalance, + ); + const showDailyFreePool = shouldShowProfileDailyFreePool( + center?.mudPointBalance, + ); + // 后端目录已按 rank 升序返回,这里只过滤不可购买与占位档。 + const membershipPlans = useMemo( + () => + (center?.membershipPlans ?? []).filter( + (plan) => plan.enabled && plan.plan !== 'normal', + ), + [center?.membershipPlans], + ); + const pointProducts = useMemo( + () => center?.pointProducts ?? [], + [center?.pointProducts], + ); + // 当前档位必须从「未过滤目录」里查:会员有效期内若该档被后台临时禁用, + // 过滤后的 membershipPlans 查不到会把有效会员误判成非会员(状态卡丢标题、按钮变「开通会员」)。 + const currentPlanRecord = useMemo( + () => + (center?.membershipPlans ?? []).find( + (plan) => plan.plan === membership?.plan, + ) ?? null, + [center?.membershipPlans, membership?.plan], + ); + // 已是最高的 max 档时没有可升级目标,状态卡不应再提示「可同周期补差升档」。 + const hasUpgradePath = useMemo( + () => + currentPlanRecord !== null && + membershipPlans.some((plan) => plan.rank > currentPlanRecord.rank), + [currentPlanRecord, membershipPlans], + ); + const [activeTab, setActiveTab] = + useState('subscription'); + const [cycleKind, setCycleKind] = + useState('monthly'); + const [selectedPointProductId, setSelectedPointProductId] = useState< + string | null + >(null); + const [checkout, setCheckout] = + useState(null); + + // 有效会员期内不允许月付 / 年付互换,切换器锁定在当前周期。 + useEffect(() => { + if (isActiveMembership && membership?.cycleKind) { + setCycleKind(membership.cycleKind); + } + }, [isActiveMembership, membership?.cycleKind]); + + useEffect(() => { + setSelectedPointProductId((current) => { + if ( + current && + pointProducts.some((product) => product.productId === current) + ) { + return current; + } + if (pointProducts.length === 0) { + return null; + } + // 中文注释:默认预选第 3 档(索引 2,不足时退回末档),让首屏落在价格居中的档位; + // 充点商品目前同价,这里只影响默认选中项,不改变计费。 + const defaultProductIndex = Math.min(2, pointProducts.length - 1); + const fallbackProduct = pointProducts[defaultProductIndex]; + return fallbackProduct ? fallbackProduct.productId : null; + }); + }, [pointProducts]); + + const selectedPointProduct = + pointProducts.find( + (product) => product.productId === selectedPointProductId, + ) ?? null; + + const openMembershipCheckout = useCallback( + (plan: ProfileMembershipPlanRecord) => { + const action = resolveMembershipPlanAction( + plan, + membership, + currentPlanRecord, + ); + if (action.disabled) { + return; + } + setCheckout({ + kind: 'membership', + plan: plan.plan, + cycleKind, + mode: action.mode, + }); + if (action.mode === 'upgrade') { + onRequestMembershipQuote(plan.plan); + } + }, + [currentPlanRecord, cycleKind, membership, onRequestMembershipQuote], + ); return ( <> } - isEmpty={products.length === 0} + isEmpty={membershipPlans.length === 0 && pointProducts.length === 0} emptyState={ 暂无可购买套餐 } > -
- {products.map((product) => ( - + + + + + +
+ {checkout ? ( + setCheckout(null)} + onBuy={onBuy} + onRequestMembershipQuote={onRequestMembershipQuote} + onSubmitMembershipCheckout={onSubmitMembershipCheckout} + /> + ) : null} {nativePayment ? ( +> = { + starter: { + estimateCount: '1–2 个', + estimateKind: '简单小游戏原型', + estimateAlt: '先验证核心玩法与基础交互', + }, + plus: { + estimateCount: '2–4 个', + estimateKind: '轻量微抖小游戏', + estimateAlt: '按可玩版本估算', + }, + pro: { + estimateCount: '1–2 个', + estimateKind: '中等项目 Demo', + estimateAlt: '或约 7–14 个轻量小游戏', + }, + max: { + estimateCount: '1–2 个', + estimateKind: '独立游戏可玩章节', + estimateAlt: '或约 2–5 个中等项目 Demo', + }, +}; + +const FALLBACK_PLAN_COPY: MembershipPlanCopy = { + estimateCount: '按实际创作量', + estimateKind: '以作品规模为准', + estimateAlt: '具体消耗取决于创作复杂度', +}; + +export function resolveMembershipPlanCopy( + plan: ProfileMembershipPlan, +): MembershipPlanCopy { + return MEMBERSHIP_PLAN_COPY[plan] ?? FALLBACK_PLAN_COPY; +} + +export function resolveMembershipModelAccessLabel( + modelAccess: ProfileMembershipModelAccess, +): string { + return modelAccess === 'full' ? '基础及高性能模型' : '基础模型'; +} + +/** 套餐卡里的并发文案;是否不设上限由后端 `unlimitedConcurrency` 标志决定。 */ +export function resolveMembershipConcurrencyLabel( + plan: Pick< + ProfileMembershipPlanRecord, + 'concurrentJobLimit' | 'unlimitedConcurrency' + >, +): string { + return plan.unlimitedConcurrency + ? '独立任务并发不设套餐上限' + : `同时运行 ${plan.concurrentJobLimit} 个独立任务`; +} + +/** 对比表里的并发短文案。 */ +export function resolveMembershipConcurrencyShortLabel( + plan: Pick< + ProfileMembershipPlanRecord, + 'concurrentJobLimit' | 'unlimitedConcurrency' + >, +): string { + return plan.unlimitedConcurrency + ? '不设套餐上限' + : `${plan.concurrentJobLimit} 个`; +} diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts new file mode 100644 index 000000000..509047d40 --- /dev/null +++ b/packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct.ts @@ -0,0 +1,15 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, +} from '../../contracts/runtime'; + +/** + * 会员商品没有配置行,`product_id` 由「档位 + 周期」拼出,与后端 + * `parse_runtime_profile_membership_product_id` 双向对应。 + */ +export function resolveMembershipProductId( + plan: ProfileMembershipPlan, + cycleKind: ProfileMembershipCycleKind, +) { + return `membership-${plan}-${cycleKind}`; +} diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index 2c3369106..c734e3ff7 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -103,6 +103,9 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { ); expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source'); expect(getWalletLedgerSourceLabel('llm_router_consume')).toBe('LLM 调用消耗'); + expect(getWalletLedgerSourceLabel('membership_upgrade_grant')).toBe( + '会员升级补点', + ); expect(getWalletLedgerSourceLabel('')).toBe('未知来源'); expect(formatWalletLedgerDate('not-a-date')).toBe('not-a-date'); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index 514c40987..8dfacbb7f 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -11,6 +11,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = { snapshot_sync: '账户同步', membership_period_grant: '会员周期发放', membership_period_reset: '会员周期重置', + membership_upgrade_grant: '会员升级补点', daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', diff --git a/packages/shared/src/components/admin/AdminFormat.test.ts b/packages/shared/src/components/admin/AdminFormat.test.ts index 13b169cab..f3a8c7466 100644 --- a/packages/shared/src/components/admin/AdminFormat.test.ts +++ b/packages/shared/src/components/admin/AdminFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +import { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; describe('formatAdminDateTime', () => { test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => { @@ -44,3 +48,11 @@ describe('formatAdminDate', () => { expect(formatAdminDate('1791008678.817257Z')).not.toBe('-'); }); }); + +describe('formatAdminCurrencyCents', () => { + test('分转成带两位小数的元', () => { + expect(formatAdminCurrencyCents(0)).toBe('¥0.00'); + expect(formatAdminCurrencyCents(600)).toBe('¥6.00'); + expect(formatAdminCurrencyCents(1234)).toBe('¥12.34'); + }); +}); diff --git a/packages/shared/src/components/admin/AdminFormat.ts b/packages/shared/src/components/admin/AdminFormat.ts index 21e10f666..d69a0edfc 100644 --- a/packages/shared/src/components/admin/AdminFormat.ts +++ b/packages/shared/src/components/admin/AdminFormat.ts @@ -54,6 +54,11 @@ export function formatAdminDateTime( } } +/** 后台统一的金额展示:分 → `¥12.34`;避免各后台页面各自实现导致格式漂移。 */ +export function formatAdminCurrencyCents(cents: number): string { + return `¥${(cents / 100).toFixed(2)}`; +} + /** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */ export function formatAdminDate( value: string | number | Date | null | undefined, diff --git a/packages/shared/src/components/admin/index.ts b/packages/shared/src/components/admin/index.ts index 8483c98ba..081fcb5ec 100644 --- a/packages/shared/src/components/admin/index.ts +++ b/packages/shared/src/components/admin/index.ts @@ -19,7 +19,11 @@ export type { AdminFormRowProps, } from './AdminField'; export { AdminField, AdminFieldGroup, AdminFormRow } from './AdminField'; -export { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +export { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; export type { AdminInfoItemProps, AdminInfoListEntry, diff --git a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts new file mode 100644 index 000000000..139a2608b --- /dev/null +++ b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +/** + * 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 + */ +export type AdminUpsertProfileMembershipPlanRequest = { +/** + * 档位 token,作为主键即档位身份。 + */ +plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPriceCents: number, periodPoints: number, +/** + * 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 + */ +modelAccess: ProfileMembershipModelAccessToken, +/** + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 + */ +concurrentJobLimit: number, enabled: boolean, }; diff --git a/packages/shared/src/contracts/generated/AgcAgentMode.ts b/packages/shared/src/contracts/generated/AgcAgentMode.ts new file mode 100644 index 000000000..a23cf0075 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcAgentMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 客户端启动的执行器。与线协议正交。 + */ +export type AgcAgentMode = "codex" | "cc"; diff --git a/packages/shared/src/contracts/generated/AgcModelProtocol.ts b/packages/shared/src/contracts/generated/AgcModelProtocol.ts new file mode 100644 index 000000000..c6fa1e618 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcModelProtocol.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 上游线协议,与执行器正交。 + * + * `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 + * `/api/llm/v1/chat/completions`;`anthropic` 走独立的 + * `/api/llm/anthropic/{*path}`。 + */ +export type AgcModelProtocol = "openai_responses" | "openai_chat" | "anthropic"; diff --git a/packages/shared/src/contracts/generated/LlmModelSummary.ts b/packages/shared/src/contracts/generated/LlmModelSummary.ts new file mode 100644 index 000000000..97c5e6ad6 --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelSummary.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; + +/** + * 本档可用的一条模型摘要。不包含上游真实模型名。 + */ +export type LlmModelSummary = { displayName: string, id: string, +/** + * 执行器;旧服务端缺字段时按 `codex` 兼容。 + */ +agentMode: AgcAgentMode, +/** + * 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 + */ +protocol: AgcModelProtocol, }; diff --git a/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts new file mode 100644 index 000000000..a9a55039a --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 模型对当前账号不可用的原因。 + * + * `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 + */ +export type LlmModelUnavailableReason = "plan_required" | "disabled" | "unknown"; diff --git a/packages/shared/src/contracts/generated/LlmModelsResponse.ts b/packages/shared/src/contracts/generated/LlmModelsResponse.ts new file mode 100644 index 000000000..ce1a75fef --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelsResponse.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LlmModelSummary } from "./LlmModelSummary"; +import type { LlmUnavailableModel } from "./LlmUnavailableModel"; + +/** + * `GET /api/llm/models` 的响应。 + */ +export type LlmModelsResponse = { defaultModelId: string, +/** + * 本档可用,保持目录顺序。 + */ +models: Array, +/** + * 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + */ +unavailableModels: Array, +/** + * 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + */ +revision: number, }; diff --git a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts new file mode 100644 index 000000000..119e03dc7 --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; +import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason"; + +/** + * 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 + */ +export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, +/** + * 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 + */ +protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; diff --git a/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts b/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts new file mode 100644 index 000000000..398f8425b --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileDailyFreePointsResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileDailyFreePointsResponse = { dayKey: number, grantedPoints: number, remainingPoints: number, resetsAt: string, updatedAt: string, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts new file mode 100644 index 000000000..ea6b35979 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipChangeKindToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员订单权益变更类型 token;区分“新购”与“补差升级”。 + */ +export type ProfileMembershipChangeKindToken = "purchase" | "upgrade"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts new file mode 100644 index 000000000..878043568 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipCycleKindToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。 + */ +export type ProfileMembershipCycleKindToken = "monthly" | "yearly"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts new file mode 100644 index 000000000..1c3f3f95f --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipModelAccessToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 + */ +export type ProfileMembershipModelAccessToken = "basic" | "full"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts new file mode 100644 index 000000000..8749193c5 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanAdminListResponse.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipPlanResponse } from "./ProfileMembershipPlanResponse"; + +export type ProfileMembershipPlanAdminListResponse = { entries: Array, +/** + * 并发哨兵阈值:`concurrent_job_limit >= 该值` 即不设上限;由后端下发,前端不硬编码。 + */ +unlimitedConcurrencyThreshold: number, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts new file mode 100644 index 000000000..c2ba7fd73 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +export type ProfileMembershipPlanResponse = { +/** + * 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 + */ +plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents: number, yearPriceCents: number, periodPoints: number, +/** + * 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 + */ +modelAccess: ProfileMembershipModelAccessToken, +/** + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 + */ +concurrentJobLimit: number, +/** + * 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + */ +recommended: boolean, +/** + * 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + */ +unlimitedConcurrency: boolean, enabled: boolean, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts new file mode 100644 index 000000000..03de552cd --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanToken.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员档位 token;与前端 `ProfileMembershipPlan` 联合类型一一对应。 + * + * 用枚举而非 `String` 收口 wire 值:未知 token 在反序列化阶段即被拒绝,后台也不能再 + * 写入任意档位字符串。 + */ +export type ProfileMembershipPlanToken = "normal" | "starter" | "plus" | "pro" | "max"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts new file mode 100644 index 000000000..0dd646fd0 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipCycleKindToken } from "./ProfileMembershipCycleKindToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; +import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToken"; + +export type ProfileMembershipResponse = { +/** + * 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 + */ +status: ProfileMembershipStatusToken, +/** + * 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 + */ +plan: ProfileMembershipPlanToken, startedAt: string | null, expiresAt: string | null, updatedAt: string | null, cycleStartedAt: string | null, cycleResetsAt: string | null, cycleGrantedPoints: number, cycleRemainingPoints: number, +/** + * 当前期序号(1-based)。 + */ +cycleIndex: number, +/** + * 本次购买总期数(月付 1 / 年付 12)。 + */ +cycleCount: number, +/** + * 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 + */ +cycleKind: ProfileMembershipCycleKindToken, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts b/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts new file mode 100644 index 000000000..f2b14056e --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipStatusToken.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会员状态 token;与前端 `ProfileMembershipStatus` 联合类型一一对应。 + */ +export type ProfileMembershipStatusToken = "normal" | "active"; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts new file mode 100644 index 000000000..785ffdd1f --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteRequest.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +export type ProfileMembershipUpgradeQuoteRequest = { +/** + * 目标档位 token:`starter` / `plus` / `pro` / `max`。 + */ +targetPlan: ProfileMembershipPlanToken, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts new file mode 100644 index 000000000..9301658ab --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMembershipUpgradeQuoteResponse.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileMembershipCycleKindToken } from "./ProfileMembershipCycleKindToken"; +import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken"; + +/** + * 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 + */ +export type ProfileMembershipUpgradeQuoteResponse = { +/** + * 当前档位 token;未知取值按 `max` 兜底。 + */ +currentPlan: ProfileMembershipPlanToken, +/** + * 目标档位 token;未知取值按 `max` 兜底。 + */ +targetPlan: ProfileMembershipPlanToken, +/** + * 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致; + * 未知取值按 `yearly` 兜底。 + */ +cycleKind: ProfileMembershipCycleKindToken, cycleIndex: number, cycleCount: number, +/** + * 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + */ +amountCents: number, +/** + * 本期补点;年付按本期剩余比例向下取整。 + */ +grantedPointsDelta: number, +/** + * 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + */ +monthlyBalanceAfter: number, expiresAt: string | null, cycleResetsAt: string | null, remainingFullMonths: number, }; diff --git a/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts new file mode 100644 index 000000000..5a0ad0526 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileMudPointBalanceResponse = { +/** + * 每日免费泥点:北京业务日有效,跨日清零并重置。 + */ +dailyFreePoints: number, dailyFreeResetPoints: number, dailyFreeResetsAt: string, +/** + * 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + */ +monthlyPoints: number, monthlyResetsAt: string | null, +/** + * 永久泥点:不过期,创作照常扣点。 + */ +permanentPoints: number, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts new file mode 100644 index 000000000..4eb1a0a85 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeCenterResponse.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProfileDailyFreePointsResponse } from "./ProfileDailyFreePointsResponse"; +import type { ProfileMembershipPlanResponse } from "./ProfileMembershipPlanResponse"; +import type { ProfileMembershipResponse } from "./ProfileMembershipResponse"; +import type { ProfileMudPointBalanceResponse } from "./ProfileMudPointBalanceResponse"; +import type { ProfileRechargeOrderResponse } from "./ProfileRechargeOrderResponse"; +import type { ProfileRechargeProductResponse } from "./ProfileRechargeProductResponse"; + +export type ProfileRechargeCenterResponse = { mudPointBalance: ProfileMudPointBalanceResponse, membership: ProfileMembershipResponse, pointProducts: Array, +/** + * 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + */ +membershipPlans: Array, latestOrder: ProfileRechargeOrderResponse | null, hasPointsRecharged: boolean, dailyFreePoints: ProfileDailyFreePointsResponse, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts new file mode 100644 index 000000000..7178fb1de --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeOrderResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileRechargeOrderResponse = { orderId: string, productId: string, productTitle: string, kind: string, amountCents: number, status: string, paymentChannel: string, paidAt: string | null, providerTransactionId: string | null, createdAt: string, pointsDelta: number, membershipExpiresAt: string | null, expiredAt: string | null, expirationCheckedAt: string | null, expirationProviderState: string | null, expirationLastError: string | null, }; diff --git a/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts b/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts new file mode 100644 index 000000000..84f522479 --- /dev/null +++ b/packages/shared/src/contracts/generated/ProfileRechargeProductResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProfileRechargeProductResponse = { productId: string, title: string, priceCents: number, kind: string, pointsAmount: number, bonusPoints: number, durationDays: number, badgeLabel: string, description: string, }; diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 033d5b171..7d80f9ad0 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -1,4 +1,38 @@ import type { JsonObject } from './common'; +import type { AdminUpsertProfileMembershipPlanRequest } from './generated/AdminUpsertProfileMembershipPlanRequest'; +import type { ProfileDailyFreePointsResponse } from './generated/ProfileDailyFreePointsResponse'; +import type { ProfileMembershipChangeKindToken } from './generated/ProfileMembershipChangeKindToken'; +import type { ProfileMembershipCycleKindToken } from './generated/ProfileMembershipCycleKindToken'; +import type { ProfileMembershipModelAccessToken } from './generated/ProfileMembershipModelAccessToken'; +import type { ProfileMembershipPlanAdminListResponse } from './generated/ProfileMembershipPlanAdminListResponse'; +import type { ProfileMembershipPlanResponse } from './generated/ProfileMembershipPlanResponse'; +import type { ProfileMembershipPlanToken } from './generated/ProfileMembershipPlanToken'; +import type { ProfileMembershipResponse } from './generated/ProfileMembershipResponse'; +import type { ProfileMembershipStatusToken } from './generated/ProfileMembershipStatusToken'; +import type { ProfileMembershipUpgradeQuoteRequest } from './generated/ProfileMembershipUpgradeQuoteRequest'; +import type { ProfileMembershipUpgradeQuoteResponse } from './generated/ProfileMembershipUpgradeQuoteResponse'; +import type { ProfileMudPointBalanceResponse as ProfileMudPointBalance } from './generated/ProfileMudPointBalanceResponse'; +import type { ProfileRechargeCenterResponse as GeneratedProfileRechargeCenterResponse } from './generated/ProfileRechargeCenterResponse'; +import type { ProfileRechargeOrderResponse } from './generated/ProfileRechargeOrderResponse'; +import type { ProfileRechargeProductResponse } from './generated/ProfileRechargeProductResponse'; + +export type { + AdminUpsertProfileMembershipPlanRequest, + ProfileDailyFreePointsResponse, + ProfileMembershipChangeKindToken, + ProfileMembershipCycleKindToken, + ProfileMembershipModelAccessToken, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanResponse, + ProfileMembershipPlanToken, + ProfileMembershipResponse, + ProfileMembershipStatusToken, + ProfileMembershipUpgradeQuoteRequest, + ProfileMembershipUpgradeQuoteResponse, + ProfileMudPointBalance, + ProfileRechargeOrderResponse, + ProfileRechargeProductResponse, +}; export const SAVE_SNAPSHOT_VERSION = 2; export const DEFAULT_MUSIC_VOLUME = 0.42; @@ -54,13 +88,7 @@ export type ProfileDashboardSummary = { dailyFreePoints?: ProfileDailyFreePoints; }; -export type ProfileDailyFreePoints = { - dayKey: number; - grantedPoints: number; - remainingPoints: number; - resetsAt: string; - updatedAt: string; -}; +export type ProfileDailyFreePoints = ProfileDailyFreePointsResponse; export type ProfileWalletLedgerEntry = { id: string; @@ -74,6 +102,7 @@ export type ProfileWalletLedgerEntry = { | 'points_recharge' | 'membership_period_grant' | 'membership_period_reset' + | 'membership_upgrade_grant' | 'daily_free_grant' | 'daily_free_reset' | 'asset_operation_consume' @@ -117,16 +146,14 @@ export type ExternalApiKeyMutationResponse = { }; export type ProfileRechargeProductKind = 'points' | 'membership'; -export type ProfileMembershipStatus = 'normal' | 'active'; -export type ProfileMembershipTier = - | 'normal' - | 'month' - | 'season' - | 'year' - | 'starter' - | 'basic' - | 'pro' - | 'ultimate'; +/** 会员状态:来源 Rust `ProfileMembershipStatusToken`,禁止手工维护字面量。 */ +export type ProfileMembershipStatus = ProfileMembershipStatusToken; +/** 会员档位:`normal` 是非会员占位档位,不可购买。 */ +export type ProfileMembershipPlan = ProfileMembershipPlanToken; +/** 会员周期:有效期内不支持月年互换,到期后重新选择。 */ +export type ProfileMembershipCycleKind = ProfileMembershipCycleKindToken; +/** 模型权限:`basic` 仅基础模型,`full` 基础及高性能模型。 */ +export type ProfileMembershipModelAccess = ProfileMembershipModelAccessToken; export type ProfileRechargeOrderStatus = | 'pending' | 'paid' @@ -135,88 +162,39 @@ export type ProfileRechargeOrderStatus = | 'refunded' | 'expired'; -export type ProfileRechargeProduct = { - productId: string; - title: string; - priceCents: number; +/** 充点 / 会员商品;`kind` 是前端收窄的联合类型,其余字段以后端生成为准。 */ +export type ProfileRechargeProduct = Omit< + ProfileRechargeProductResponse, + 'kind' +> & { kind: ProfileRechargeProductKind; - pointsAmount: number; - bonusPoints: number; - durationDays: number; - badgeLabel: string; - description: string; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; }; -export type ProfileMembershipBenefit = { - benefitName: string; - normalValue: string; - monthValue: string; - seasonValue: string; - yearValue: string; - starterValue: string; - basicValue: string; - proValue: string; - ultimateValue: string; -}; +/** 会员档位目录行;价格与权益的唯一真相源是后端 `profile_membership_plan` 表。 */ +export type ProfileMembershipPlanRecord = ProfileMembershipPlanResponse; -export type ProfileMembership = { - status: ProfileMembershipStatus; - tier: ProfileMembershipTier; - startedAt: string | null; - expiresAt: string | null; - updatedAt: string | null; - cycleStartedAt: string | null; - cycleResetsAt: string | null; - cycleGrantedPoints: number; - cycleRemainingPoints: number; - cyclePeriodDays: number; -}; +export type ProfileMembership = ProfileMembershipResponse; -export type ProfileMudPointBalance = { - totalPoints: number; - permanentPoints: number; - limitedPoints: number; - limitedExpiresAt: string | null; - dailyFreePoints: number; - dailyFreeResetPoints: number; - dailyFreeResetsAt: string; -}; +/** 补差升级报价;下单时后端会用同一套规则重算,前端展示的数字不是真相源。 */ +export type ProfileMembershipUpgradeQuote = + ProfileMembershipUpgradeQuoteResponse; -export type ProfileRechargeOrder = { - orderId: string; - productId: string; - productTitle: string; +/** 充值订单;`kind` / `status` 是前端收窄的联合类型,其余字段以后端生成为准。 */ +export type ProfileRechargeOrder = Omit< + ProfileRechargeOrderResponse, + 'kind' | 'status' +> & { kind: ProfileRechargeProductKind; - amountCents: number; status: ProfileRechargeOrderStatus; - paymentChannel: string; - paidAt: string | null; - providerTransactionId: string | null; - createdAt: string; - pointsDelta: number; - membershipExpiresAt: string | null; - expiredAt?: string | null; - expirationCheckedAt?: string | null; - expirationProviderState?: string | null; - expirationLastError?: string | null; }; -export type ProfileRechargeCenterResponse = { - walletBalance: number; - // TODO mirror backend: should not nullable - mudPointBalance?: ProfileMudPointBalance; - membership: ProfileMembership; +/** 充值中心;结构以后端生成为准,仅把商品 / 订单收窄成带联合类型的本地别名。 */ +export type ProfileRechargeCenterResponse = Omit< + GeneratedProfileRechargeCenterResponse, + 'pointProducts' | 'latestOrder' +> & { pointProducts: ProfileRechargeProduct[]; - membershipProducts: ProfileRechargeProduct[]; - benefits: ProfileMembershipBenefit[]; latestOrder: ProfileRechargeOrder | null; - hasPointsRecharged: boolean; - dailyFreePoints?: ProfileDailyFreePoints; }; export type WechatMiniProgramPayParams = { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 568bb3382..cf0fbb683 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './components'; +export * from './components/PlatformProfileRechargeModal/membershipProduct'; export * from './contracts/auth'; export type * from './contracts/clientDownload'; export * from './contracts/common'; @@ -10,7 +11,9 @@ export type * from './contracts/hyper3d'; export * from './contracts/payment'; export * from './contracts/runtime'; export * from './http'; +export type * from './llm/modelCatalog'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; export * from './stores/createProfileWalletStore'; +export * from './utils/mudPoints'; export * from './utils/signedReadUrlCache'; diff --git a/packages/shared/src/llm/modelCatalog.ts b/packages/shared/src/llm/modelCatalog.ts new file mode 100644 index 000000000..a483dd19a --- /dev/null +++ b/packages/shared/src/llm/modelCatalog.ts @@ -0,0 +1,12 @@ +/** + * AGC LLM 模型目录契约(Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物)。 + * + * 前端不再手写第二份 `ClientLlmModel`:可用 / 不可用分桶、`reason` 枚举与 + * `agentMode` / `protocol` 联合类型都以这里为准。 + */ +export type { AgcAgentMode } from '../contracts/generated/AgcAgentMode'; +export type { AgcModelProtocol } from '../contracts/generated/AgcModelProtocol'; +export type { LlmModelsResponse } from '../contracts/generated/LlmModelsResponse'; +export type { LlmModelSummary } from '../contracts/generated/LlmModelSummary'; +export type { LlmModelUnavailableReason } from '../contracts/generated/LlmModelUnavailableReason'; +export type { LlmUnavailableModel } from '../contracts/generated/LlmUnavailableModel'; diff --git a/packages/shared/src/stores/createProfileWalletStore.test.ts b/packages/shared/src/stores/createProfileWalletStore.test.ts index c216c73dd..daba9d20f 100644 --- a/packages/shared/src/stores/createProfileWalletStore.test.ts +++ b/packages/shared/src/stores/createProfileWalletStore.test.ts @@ -8,10 +8,9 @@ import { createProfileWalletStore } from './createProfileWalletStore'; function balance(totalPoints: number): ProfileMudPointBalance { return { - totalPoints, permanentPoints: totalPoints, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -254,31 +253,29 @@ describe('createProfileWalletStore', () => { expect(store.getState().mudPointBalance?.totalPoints).toBe(100); }); - test('retains a legacy total without inventing a balance breakdown', async () => { + test('treats a center response without a balance breakdown as an error', async () => { const store = createProfileWalletStore({ - getRechargeCenter: vi.fn().mockResolvedValue({ - walletBalance: 37, - } as ProfileRechargeCenterResponse), + getRechargeCenter: vi + .fn() + .mockResolvedValue({} as ProfileRechargeCenterResponse), }); store.getState().setWalletOwner('user-a'); await store.getState().onWalletBalanceMayHaveChanged(); expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, + legacyWalletBalance: null, mudPointBalance: null, mudPointBalanceStatus: 'error', mudPointBalanceError: '泥点明细读取失败', }); }); - test('clears a stale balance breakdown when a newer refresh only has a legacy total', async () => { + test('keeps the last known good breakdown when a newer refresh fails', async () => { const getRechargeCenter = vi .fn() .mockResolvedValueOnce(center(90)) - .mockResolvedValueOnce({ - walletBalance: 37, - } as ProfileRechargeCenterResponse); + .mockRejectedValueOnce(new Error('network down')); const store = createProfileWalletStore({ getRechargeCenter }); store.getState().setWalletOwner('user-a'); @@ -286,35 +283,22 @@ describe('createProfileWalletStore', () => { await store.getState().onWalletBalanceMayHaveChanged(); expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, - mudPointBalance: null, + legacyWalletBalance: 90, mudPointBalanceStatus: 'error', mudPointBalanceError: '泥点明细读取失败', }); + expect(store.getState().mudPointBalance?.permanentPoints).toBe(90); }); - test('applies an owner-scoped legacy mutation snapshot atomically', () => { + test('rejects a snapshot from another owner', () => { const store = createProfileWalletStore({ getRechargeCenter: vi.fn() }); store.getState().setWalletOwner('user-a'); - const structuredSnapshot = store - .getState() - .captureWalletBalanceSnapshot('user-a'); - store - .getState() - .applyWalletBalanceSnapshot(structuredSnapshot!, balance(90)); - const legacySnapshot = store - .getState() - .captureWalletBalanceSnapshot('user-a'); + const snapshot = store.getState().captureWalletBalanceSnapshot('user-b'); expect( - store.getState().applyLegacyWalletBalanceSnapshot(legacySnapshot!, 37), - ).toBe(true); - expect(store.getState()).toMatchObject({ - legacyWalletBalance: 37, - mudPointBalance: null, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + store.getState().applyWalletBalanceSnapshot(snapshot, balance(37)), + ).toBe(false); + expect(store.getState().mudPointBalance).toBeNull(); }); test('rejects a snapshot after switching away from and back to the same owner', () => { diff --git a/packages/shared/src/stores/createProfileWalletStore.ts b/packages/shared/src/stores/createProfileWalletStore.ts index 4cace6e85..c63c032c6 100644 --- a/packages/shared/src/stores/createProfileWalletStore.ts +++ b/packages/shared/src/stores/createProfileWalletStore.ts @@ -4,6 +4,10 @@ import type { ProfileMudPointBalance, ProfileRechargeCenterResponse, } from '../contracts/runtime'; +import { + type ProfileMudPointBalanceView, + resolveProfileMudPointBalanceView, +} from '../utils/mudPoints'; export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -22,7 +26,7 @@ export type ProfileWalletBalanceSnapshot = Readonly<{ export type ProfileWalletStore = { ownerUserId: string | null; - mudPointBalance: ProfileMudPointBalance | null; + mudPointBalance: ProfileMudPointBalanceView | null; legacyWalletBalance: number | null; mudPointBalanceStatus: MudPointBalanceStatus; mudPointBalanceError: string; @@ -31,13 +35,9 @@ export type ProfileWalletStore = { ownerUserId: string, ) => ProfileWalletBalanceSnapshot | null; applyWalletBalanceSnapshot: ( - snapshot: ProfileWalletBalanceSnapshot, + snapshot: ProfileWalletBalanceSnapshot | null, balance: ProfileMudPointBalance, ) => boolean; - applyLegacyWalletBalanceSnapshot: ( - snapshot: ProfileWalletBalanceSnapshot, - balance: number, - ) => boolean; onWalletBalanceMayHaveChanged: () => Promise; resetWalletBalance: () => void; }; @@ -49,6 +49,20 @@ const EMPTY_WALLET_STATE = { mudPointBalanceError: '', } as const; +/** + * 余额读取成功后的统一就绪态:快照应用与刷新循环共用同一段派生逻辑, + * 避免 `totalPoints` 与 ready 字段在两条路径上各自漂移。 + */ +function readyWalletBalanceState(balance: ProfileMudPointBalance) { + const mudPointBalance = resolveProfileMudPointBalanceView(balance); + return { + mudPointBalance, + legacyWalletBalance: mudPointBalance.totalPoints, + mudPointBalanceStatus: 'ready' as const, + mudPointBalanceError: '', + }; +} + export function createProfileWalletStore( api: ProfileWalletApi, ): UseBoundStore> { @@ -103,6 +117,11 @@ export function createProfileWalletStore( }; }, applyWalletBalanceSnapshot: (snapshot, balance) => { + // captureWalletBalanceSnapshot 在 owner 不匹配时返回 null;null 本身即「快照已失效」, + // 直接拒绝,避免把 capture 的可空返回喂回 apply 时解引用崩溃。 + if (!snapshot) { + return false; + } if ( get().ownerUserId !== snapshot.ownerUserId || ownerVersion !== snapshot.ownerVersion || @@ -119,38 +138,7 @@ export function createProfileWalletStore( snapshot.operationSequence, ); settledRefreshVersion = snapshot.invalidationVersion; - set({ - mudPointBalance: balance, - legacyWalletBalance: balance.totalPoints, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); - return true; - }, - applyLegacyWalletBalanceSnapshot: (snapshot, balance) => { - if ( - !Number.isFinite(balance) || - get().ownerUserId !== snapshot.ownerUserId || - ownerVersion !== snapshot.ownerVersion || - snapshot.invalidationVersion < refreshVersion || - snapshot.invalidationVersion < settledRefreshVersion || - snapshot.operationSequence < latestAppliedOperationSequence - ) { - return false; - } - - invalidateActiveRefresh(); - latestAppliedOperationSequence = Math.max( - latestAppliedOperationSequence, - snapshot.operationSequence, - ); - settledRefreshVersion = snapshot.invalidationVersion; - set({ - mudPointBalance: null, - legacyWalletBalance: balance, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + set(readyWalletBalanceState(balance)); return true; }, onWalletBalanceMayHaveChanged: () => { @@ -181,22 +169,11 @@ export function createProfileWalletStore( continue; } if (!center.mudPointBalance) { - if (Number.isFinite(center.walletBalance)) { - set({ - mudPointBalance: null, - legacyWalletBalance: center.walletBalance, - }); - } throw new Error('充值中心响应缺少泥点余额'); } settledRefreshVersion = requestedVersion; - set({ - mudPointBalance: center.mudPointBalance, - legacyWalletBalance: center.mudPointBalance.totalPoints, - mudPointBalanceStatus: 'ready', - mudPointBalanceError: '', - }); + set(readyWalletBalanceState(center.mudPointBalance)); } catch { if (generation !== requestGeneration) { return; diff --git a/packages/shared/src/utils/mudPoints.ts b/packages/shared/src/utils/mudPoints.ts new file mode 100644 index 000000000..5e78425b0 --- /dev/null +++ b/packages/shared/src/utils/mudPoints.ts @@ -0,0 +1,105 @@ +import type { ProfileMudPointBalance } from '../contracts/runtime'; + +/** + * 泥点余额的展示视图。 + * + * 后端只并列暴露三个池子(每日免费 / 月度 / 永久),不直接下发合计字段;前端统一把三池 + * 相加后的值以「可用总额」展示。需要合计展示时统一走 + * {@link resolveProfileMudPointBalanceView},避免各调用点各自相加分叉。 + */ +export type ProfileMudPointBalanceView = ProfileMudPointBalance & { + /** 每日免费 + 月度 + 永久;前端统一以「可用总额」展示。 */ + totalPoints: number; +}; + +export function resolveProfileMudPointBalanceView( + balance: ProfileMudPointBalance, +): ProfileMudPointBalanceView { + // 三个池子按 0 兜底:接口缺字段时宁可显示 0,也不能让合计变成 NaN。 + return { + ...balance, + dailyFreePoints: balance.dailyFreePoints ?? 0, + dailyFreeResetPoints: balance.dailyFreeResetPoints ?? 0, + monthlyPoints: balance.monthlyPoints ?? 0, + permanentPoints: balance.permanentPoints ?? 0, + totalPoints: + (balance.dailyFreePoints ?? 0) + + (balance.monthlyPoints ?? 0) + + (balance.permanentPoints ?? 0), + }; +} + +/** + * 从任意余额形状推导展示用合计;三池缺省按 0 处理,整体为 `undefined` 时返回 `undefined`。 + */ +export function resolveProfileMudPointDisplayTotal( + balance: ProfileMudPointBalance | null | undefined, +): number | undefined { + if (!balance) { + return undefined; + } + return resolveProfileMudPointBalanceView(balance).totalPoints; +} + +/** + * 是否展示「每日免费」池。 + * + * 后端把每日发放额 `dailyFreeResetPoints` 配置为 0 时,表示不再提供该池, + * 前端不再展示每日免费相关入口;配置随时可改回 > 0,无需额外开关。当天仍有存量 + * (`dailyFreePoints > 0`)时仍要展示:扣点顺序不变,这部分存量会被优先扣减, + * 不能隐身。 + */ +export function shouldShowProfileDailyFreePool( + balance: + | Pick + | null + | undefined, +): boolean { + if (!balance) { + return false; + } + return ( + (balance.dailyFreePoints ?? 0) > 0 || + (balance.dailyFreeResetPoints ?? 0) > 0 + ); +} + +/** + * 月度泥点刷新时刻的格式化器:locale / 时区 / 字段固定,提到模块级复用。 + * + * 固定 `Asia/Shanghai` 与 ADR「会员账期按开通日自然月」一致;`hourCycle: 'h23'` 避免 + * 午夜格式化成 24 点。该 formatter 会在多个渲染路径里被反复调用,构建一次即可。 + */ +const MUD_POINT_RESET_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}); + +/** + * 月度泥点的刷新时刻展示文案。 + * + * 账期锚点按北京时间定义(见 ADR「会员账期按开通日自然月」),所以展示也固定用北京时间, + * 不随设备时区漂移;无会员周期或时间非法时返回 `null`,由调用方给兜底说明。 + */ +export function formatMudPointResetAt( + value: string | null | undefined, +): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + const parts = MUD_POINT_RESET_FORMATTER.formatToParts(parsed); + const lookup = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? '00'; + return `${lookup('year')}-${lookup('month')}-${lookup('day')} ${lookup('hour')}:${lookup( + 'minute', + )}`; +} diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index f909a3986..298f8dc43 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -4,6 +4,13 @@ import http from 'node:http'; const port = Number(process.argv[2] ?? 8099); +/** 与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致的假数据哨兵。 + * + * 后端定义:`server-rs/crates/module-runtime/src/membership/limits.rs` + * (`pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128`;`>=` 该值视为不设上限)。 + * 改后端常量时请同步这里,否则本地预览的「不限并发」会与线上不一致。 */ +const UNLIMITED_CONCURRENCY_THRESHOLD = 128; + const admin = { subject: 'admin:local', username: 'owner', @@ -447,20 +454,45 @@ const userDetail = { createdAt: iso(60 * 24 * 120), lastLoginAt: iso(30), wallet: { - balance: 1860, - permanentPoints: 1200, + userId: 'u_1001', + totalBalance: 1860, spendableBalance: 1660, + dailyFreePoints: 0, + monthlyPoints: 0, + permanentPoints: 1200, heldPoints: 200, + refundDebtPoints: 0, walletFrozen: false, manualFrozen: false, refundDebtFrozen: false, }, + membership: { + plan: 'plus', + status: 'active', + cycleKind: 'monthly', + cycleIndex: 1, + cycleCount: 1, + cycleGrantedPoints: 600, + cycleRemainingPoints: 320, + startedAtMicros: 1_790_640_000_000_000, + expiresAtMicros: 1_793_232_000_000_000, + cycleResetsAtMicros: 1_793_232_000_000_000, + updatedAtMicros: 1_790_640_000_000_000, + }, rechargeOrders: [], restrictions: {}, tags: ['vip', '内测'], }; -function route(pathname, method, url) { +function parseJsonBody(requestBody) { + try { + return JSON.parse(requestBody); + } catch { + return null; + } +} + +function route(pathname, method, url, requestBody = '') { if (pathname === '/admin/api/login' && method === 'POST') { return { token: 'local-fake-token', admin }; } @@ -617,7 +649,7 @@ function route(pathname, method, url) { totalBalance: 1_860, spendableBalance: 1_660, dailyFreePoints: 0, - membershipLimitedPoints: 0, + monthlyPoints: 0, permanentPoints: 1_200, heldPoints: 200, refundDebtPoints: 0, @@ -723,28 +755,68 @@ function route(pathname, method, url) { scannedCount: orders.length, }; } - if (pathname === '/admin/api/profile/recharge-products') { - const product = ( - productId, + if (pathname === '/admin/api/profile/membership-plans') { + const plan = ( + planId, title, - priceCents, - pointsAmount, - bonusPoints, + rank, + monthPriceCents, + yearPriceCents, + periodPoints, + modelAccess, + concurrentJobLimit, + enabled, ) => ({ + plan: planId, + title, + rank, + monthPriceCents, + yearPriceCents, + periodPoints, + modelAccess, + concurrentJobLimit, + // 与后端目录单一真相源 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN` 对齐:运营推荐 Plus。 + recommended: planId === 'plus', + unlimitedConcurrency: + concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD, + enabled, + }); + const plans = [ + plan('normal', '非会员', 0, 0, 0, 0, 'basic', 1, false), + plan('starter', '入门会员', 1, 3_900, 39_000, 400, 'basic', 1, true), + plan('plus', '轻享会员', 2, 9_900, 99_000, 1_150, 'full', 3, true), + plan('pro', '专业会员', 3, 29_900, 299_000, 3_650, 'full', 5, true), + plan('max', '旗舰会员', 4, 69_900, 699_000, 8_650, 'full', 128, true), + ]; + // 本地预览按请求体回显被保存的档位:把提交字段覆盖到对应档位行,避免保存非 starter 档后表单被回填成 starter。 + if (method === 'POST') { + const submitted = parseJsonBody(requestBody); + const target = + plans.find((entry) => entry.plan === submitted?.plan) ?? plans[1]; + const merged = { ...target, ...(submitted ?? {}) }; + return { + ...merged, + unlimitedConcurrency: + Number(merged.concurrentJobLimit) >= UNLIMITED_CONCURRENCY_THRESHOLD, + }; + } + return { + entries: plans, + unlimitedConcurrencyThreshold: UNLIMITED_CONCURRENCY_THRESHOLD, + }; + } + if (pathname === '/admin/api/profile/recharge-products') { + const product = (productId, title, priceCents, pointsAmount) => ({ productId, title, priceCents, kind: 'points', pointsAmount, - bonusPoints, + // 已退役列:真实后端固定写 0,假数据也保持一致。 + bonusPoints: 0, durationDays: 0, - badgeLabel: bonusPoints ? `赠送 ${bonusPoints}` : '', + badgeLabel: '', description: `${pointsAmount} 泥点`, - tier: 'none', - membershipPeriodPoints: 0, - membershipPeriodDays: 0, - membershipQueueLimit: 0, - membershipDiscountBps: 0, enabled: true, sortOrder: 1, createdBy: 'u_admin', @@ -754,8 +826,8 @@ function route(pathname, method, url) { }); return { entries: [ - product('prod_points_600', '600 泥点', 3_000, 600, 0), - product('prod_points_1200', '1200 泥点 + 赠送 100', 6_000, 1_200, 100), + product('prod_points_600', '600 泥点', 3_000, 600), + product('prod_points_1200', '1200 泥点', 6_000, 1_200), ], }; } @@ -999,18 +1071,21 @@ function route(pathname, method, url) { alias: '标准模型', modelId: 'gpt-5.6-sol', enabled: true, + access: 'full', }, { id: 'model_terra', alias: '快速模型', modelId: 'gpt-5.6-terra', enabled: true, + access: 'basic', }, { id: 'model_legacy', alias: '旧版模型', modelId: 'gpt-5.4', enabled: false, + access: 'basic', }, ], }; @@ -1144,13 +1219,25 @@ function route(pathname, method, url) { const server = http.createServer((request, response) => { const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`); - const payload = route(url.pathname, request.method ?? 'GET', url); - const body = JSON.stringify({ data: payload }); - response.writeHead(200, { - 'Content-Type': 'application/json; charset=utf-8', - 'x-genarrative-response-envelope': 'v1', + const chunks = []; + request.on('data', (chunk) => { + chunks.push(chunk); + }); + request.on('end', () => { + const requestBody = Buffer.concat(chunks).toString('utf8'); + const payload = route( + url.pathname, + request.method ?? 'GET', + url, + requestBody, + ); + const body = JSON.stringify({ data: payload }); + response.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'x-genarrative-response-envelope': 'v1', + }); + response.end(body); }); - response.end(body); }); server.listen(port, '127.0.0.1', () => { diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 84e7c5125..909290925 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2642,7 +2642,6 @@ function assertAiGameCreatorShellUserDevBoundary() { 'function resolveEmbeddedPreviewUrl(', "url.protocol !== 'http:' || url.hostname !== '127.0.0.1'", 'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"', - 'src={embeddedUrl}', ]) { if ( !sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet) @@ -2652,6 +2651,29 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + // 预览框的 `src` 允许经一个短变量中转(刷新时要在地址上叠加 `__agc_reload` 这类非业务参数), + // 但这个变量必须由被校验过的 `embeddedUrl` 派生:只换名字、丢掉派生关系仍按漂移处理。 + const iframeUrlDerivation = aiGameCreatorLocalGamePreviewFrameSource.match( + /const\s+iframeUrl\s*=\s*useMemo\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[/, + ); + if ( + !sourceIncludesSnippet( + aiGameCreatorLocalGamePreviewFrameSource, + 'src={embeddedUrl}', + ) && + !( + sourceIncludesSnippet( + aiGameCreatorLocalGamePreviewFrameSource, + 'src={iframeUrl ?? undefined}', + ) && + iframeUrlDerivation !== null && + iframeUrlDerivation[0].includes('embeddedUrl') + ) + ) { + throw new Error( + 'AI game creator embedded preview boundary drifted: iframe src must derive from the validated embeddedUrl', + ); + } for (const snippet of [ "await invoke(\n 'activate_local_game_preview'", '已载入客户端运行视图', diff --git a/scripts/check-server-rs-ddd-boundaries.mjs b/scripts/check-server-rs-ddd-boundaries.mjs index 9a77b9633..aecacf094 100644 --- a/scripts/check-server-rs-ddd-boundaries.mjs +++ b/scripts/check-server-rs-ddd-boundaries.mjs @@ -70,7 +70,6 @@ const retiredOriginalSpacetimeSources = new Set([ 'runtime/admin_work_visibility.rs', 'runtime/browse_history.rs', 'runtime/creation_entry_config.rs', - 'runtime/profile.rs', 'runtime/settings.rs', 'runtime/snapshots.rs', ]); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index e48e102b9..84f43c482 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -54,39 +54,39 @@ const forbiddenSnippets = [ 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)', reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)', reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {', reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤', diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 03993d103..97b4ad5d0 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2263,6 +2263,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]), "/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]), "/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]), + "/admin/api/profile/membership-plans" => AnyTab(&["membership-plans"]), path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]), "/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]), "/admin/api/profile/users/reconcile-consumption" => { @@ -4845,7 +4846,8 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { 13 => "daily_free_reset", 14 => "recharge_refund_recovery", 15 => "llm_router_consume", - 16 => "game_purchase", + 16 => "membership_upgrade_grant", + 17 => "game_purchase", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), @@ -5171,8 +5173,9 @@ mod tests { }; use serde_json::{Value, json}; use shared_contracts::admin::{ - AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery, - AdminDatabaseTableRowsQuery, AdminExternalApiKeyListQuery, AdminTrackingEventListQuery, + ADMIN_TAB_PERMISSIONS, AdminConfirmEditorShowcaseCampaignImageUploadRequest, + AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminExternalApiKeyListQuery, + AdminTrackingEventListQuery, }; use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery}; use spacetime_client::{ @@ -6359,9 +6362,13 @@ mod tests { wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), Some("llm_router_consume") ); - // 买断制购买是第 17 个变体(索引 16);漏映射时后台会直接展示原始数组文本。 + // 买断制购买是第 18 个变体(索引 17);漏映射时后台会直接展示原始数组文本。 assert_eq!( wallet_ledger_source_type_to_string(&json!([16, []])).as_deref(), + Some("membership_upgrade_grant") + ); + assert_eq!( + wallet_ledger_source_type_to_string(&json!([17, []])).as_deref(), Some("game_purchase") ); assert_eq!( @@ -7600,6 +7607,11 @@ mod tests { Method::GET, "/admin/api/profile/recharge-products", ), + ( + "membership-plans", + Method::GET, + "/admin/api/profile/membership-plans", + ), ( "recharge-orders", Method::GET, @@ -7624,6 +7636,10 @@ mod tests { ]; for (permission, method, path) in cases { + assert!( + ADMIN_TAB_PERMISSIONS.contains(&permission), + "{permission} 未登记在 ADMIN_TAB_PERMISSIONS,非 owner 管理员无法被授予该 Tab" + ); assert!( enforce_admin_request_permission( "member", diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 950e53d50..1f64311a4 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -11,6 +11,7 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot, + RuntimeProfileMembershipOrderChangeSnapshot, RuntimeProfileMembershipRecord, RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, @@ -31,15 +32,16 @@ use platform_wechat::pay::{ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ - ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminProfileWalletPayload, - AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, - AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest, - AdminRechargeRefundHoldPayload, AdminRechargeRefundManualReviewResolveRequest, - AdminRechargeRefundPayload, AdminRechargeRefundPreviewRequest, - AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, - AdminUserConsumptionProjectionInitializeResponse, AdminUserConsumptionReconcileRequest, - AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse, - AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, + ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminMembershipOrderChangePayload, + AdminProfileMembershipPayload, AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, + AdminRechargeOrderListQuery, AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, + AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, + AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload, + AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, + AdminRechargeRefundRegisterRequest, AdminUserConsumptionProjectionInitializeResponse, + AdminUserConsumptionReconcileRequest, AdminUserConsumptionReconcileResponse, + AdminUserDetailQuery, AdminUserDetailResponse, AdminUserSummaryPayload, + AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload, }; use spacetime_client::SpacetimeClientError; @@ -158,6 +160,11 @@ pub async fn admin_get_user_detail( .map_err(|error| spacetime_error_response(&request_context, error))?; let cumulative_recharged_cents = fetch_admin_user_cumulative_recharged_cents(&state, &user.id).await; + let membership_record = state + .spacetime_client() + .get_profile_membership_snapshot(user.id.clone()) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; Ok(json_success_body( Some(&request_context), @@ -175,6 +182,7 @@ pub async fn admin_get_user_detail( cumulative_recharged_cents, can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE), wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)), + membership: map_membership(membership_record), recharge_orders: orders .into_iter() .map(|entry| map_order_entry(entry, Some(summary.clone()))) @@ -979,6 +987,44 @@ fn map_order_entry( remaining_refundable_cents, refund_eligible: block_reason.is_none(), refund_block_reason_code: block_reason, + membership_change: entry.membership_change.map(map_membership_order_change), + } +} + +fn map_membership_order_change( + change: RuntimeProfileMembershipOrderChangeSnapshot, +) -> AdminMembershipOrderChangePayload { + AdminMembershipOrderChangePayload { + change_kind: change.change_kind.into(), + plan_before: change.plan_before.map(Into::into), + plan_after: change.plan_after.map(Into::into), + cycle_kind_before: change.cycle_kind_before.map(Into::into), + cycle_kind_after: change.cycle_kind_after.map(Into::into), + period_points_before: change.period_points_before, + period_points_after: change.period_points_after, + granted_points_delta: change.granted_points_delta, + cycle_index: change.cycle_index, + expires_at_before_micros: change.expires_at_before_micros, + expires_at_after_micros: change.expires_at_after_micros, + cycle_resets_at_before_micros: change.cycle_resets_at_before_micros, + cycle_resets_at_after_micros: change.cycle_resets_at_after_micros, + price_breakdown_json: change.price_breakdown_json, + } +} + +fn map_membership(record: RuntimeProfileMembershipRecord) -> AdminProfileMembershipPayload { + AdminProfileMembershipPayload { + plan: record.plan.into(), + status: record.status.into(), + cycle_kind: record.cycle_kind.into(), + cycle_index: record.cycle_index, + cycle_count: record.cycle_count, + cycle_granted_points: record.cycle_granted_points, + cycle_remaining_points: record.cycle_remaining_points, + started_at_micros: record.started_at_micros, + expires_at_micros: record.expires_at_micros, + cycle_resets_at_micros: record.cycle_resets_at_micros, + updated_at_micros: record.updated_at_micros, } } @@ -991,7 +1037,7 @@ fn map_wallet( total_balance: wallet.total_balance, spendable_balance: wallet.spendable_balance, daily_free_points: wallet.daily_free_points, - membership_limited_points: wallet.membership_limited_points, + monthly_points: wallet.membership_limited_points, permanent_points: wallet.permanent_points, held_points: wallet.held_points, refund_debt_points: wallet.refund_debt_points, diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index 4875fec63..957dc9605 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -8,8 +8,8 @@ use axum::{ http::StatusCode, }; use module_runtime::{ - AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelCatalog, - AgcModelProtocol, + AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcAgentMode, AgcModelAccess, + AgcModelCatalog, AgcModelProtocol, }; use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog}; use spacetime_client::SpacetimeClientError; @@ -244,6 +244,7 @@ pub async fn admin_save_agc_models( .map_err(|message| { AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) })?; + let access = AgcModelAccess::from(m.access); Ok(module_runtime::AgcModel { id: m.id, alias: m.alias, @@ -251,6 +252,7 @@ pub async fn admin_save_agc_models( enabled: m.enabled, agent_mode, protocol, + access, }) }) .collect::, AppError>>()?; @@ -297,6 +299,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { enabled: m.enabled, agent_mode: m.agent_mode.as_str().to_string(), protocol: Some(protocol), + access: m.access.into(), } }) .collect(), @@ -324,6 +327,21 @@ mod tests { })) .unwrap(); assert_eq!(legacy.agent_mode, "codex"); + assert_eq!( + legacy.access, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic + ); + // 权限档 token 在反序列化阶段就收口,未知值不再推迟到 `AgcModelAccess::parse`。 + assert!( + serde_json::from_value::(json!({ + "id": "model-a", + "alias": "model-a", + "modelId": "model-a", + "enabled": true, + "access": "premium" + })) + .is_err() + ); } #[test] diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 9ba5795db..0b37b9980 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -695,6 +695,8 @@ fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> boo || message.contains("连接已断开") || message.contains("连接在返回结果前已断开") } + // 业务拒绝是服务端已明确应答,不应触发退款应急 spool。 + SpacetimeClientError::ProcedureRejected { .. } => false, } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index db2e78e56..602771e28 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1149,6 +1149,7 @@ fn is_editor_generation_retryable_transport_error(error: &SpacetimeClientError) || message.contains("连接在返回结果前已断开") } SpacetimeClientError::Procedure(_) => false, + SpacetimeClientError::ProcedureRejected { .. } => false, } } @@ -1160,6 +1161,7 @@ fn is_editor_generation_unknown_result_error( && !matches!( error, SpacetimeClientError::Procedure(_) + | SpacetimeClientError::ProcedureRejected { .. } | SpacetimeClientError::Timeout( SpacetimeClientStage::PoolAcquire | SpacetimeClientStage::ConnectBuild diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 351ffbb52..bbff617a8 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -9,11 +9,12 @@ use axum::{ }, }; use futures_util::StreamExt; +use module_runtime::AgcModelResolveError; use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, - LlmModelSummary, LlmModelsResponse, + LlmModelSummary, LlmModelsResponse, LlmUnavailableModel, }; use spacetime_client::SpacetimeClientError; use std::convert::Infallible; @@ -42,29 +43,106 @@ mod anthropic_bridge; pub(crate) mod icon_specs; +mod model_access; + +use model_access::{OwnerAgcModelCatalog, resolve_owner_agc_model_access}; + #[cfg(test)] mod model_catalog_tests { use super::*; #[test] - fn public_catalog_exposes_stable_id_and_upstream_alias() { + fn public_catalog_splits_available_and_disabled_models() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 7, ) .expect("catalog should build"); catalog.models[1].enabled = false; - let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Full, + }) + .unwrap(), + ) + .unwrap(); // 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。 assert_eq!( payload["models"], json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}]) ); + // 后台停用的模型仍下发到不可用桶,带 disabled 原因。 + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); assert!(payload.get("defaultModel").is_none()); assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); + assert!(payload["unavailableModels"][0].get("modelId").is_none()); + } + + #[test] + fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 0, + ) + .expect("catalog should build"); + // 登记的默认项是 Full;Basic 档看不到它,默认项回退到目录顺序里第一个可用项。 + catalog.models[0].access = module_runtime::AgcModelAccess::Full; + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Basic, + }) + .unwrap(), + ) + .unwrap(); + assert_eq!( + payload["models"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) + ); + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}]) + ); + assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); + } + + #[test] + fn plan_violation_maps_to_forbidden_with_dedicated_code() { + let violation = + agc_model_resolve_error(module_runtime::AgcModelResolveError::NotAvailableForPlan); + assert_eq!(violation.status_code(), StatusCode::FORBIDDEN); + assert_eq!( + violation.code(), + module_runtime::MODEL_NOT_AVAILABLE_FOR_PLAN + ); + let unavailable = + agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable); + assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn tier_without_model_maps_to_service_unavailable_with_dedicated_code() { + // 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422; + // 码也不再与「单个模型不可用」共用 MODEL_UNAVAILABLE。 + let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.code(), module_runtime::MODEL_UNAVAILABLE_FOR_TIER); + } + + #[test] + fn access_lookup_failure_maps_to_service_unavailable_with_dedicated_code() { + // 读链路失败必须是独立的 503 + 专用错误码,不能与 403 档位不足 / 422 模型不可用混用, + // 也不能把付费账号静默降级成 Basic。 + let error = model_access::model_access_unavailable_error(); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.code(), model_access::MODEL_ACCESS_UNAVAILABLE); } } @@ -110,12 +188,7 @@ pub async fn proxy_llm_chat_completions( let (llm_client, key_id) = match resolve_llm_router_client(&state, authenticated.claims().user_id()).await { Ok((client, key_id)) => (client, key_id), - Err(error) => { - return Err(llm_error_response( - &request_context, - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error), - )); - } + Err(error) => return Err(llm_error_response(&request_context, error)), }; prepare_llm_router_billing(&state, authenticated.claims().user_id()) .await @@ -195,32 +268,52 @@ pub async fn list_llm_models( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let owner_catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - Ok(json_success_body(Some(&request_context), public_model_catalog(catalog)).into_response()) + let catalog = public_model_catalog(owner_catalog) + .map_err(|error| llm_error_response(&request_context, error))?; + Ok(json_success_body(Some(&request_context), catalog).into_response()) } -fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .filter(|model| model.enabled) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id, - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias, - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } - }) - .collect(), +fn public_model_catalog( + owner_catalog: OwnerAgcModelCatalog, +) -> Result { + let OwnerAgcModelCatalog { catalog, access } = owner_catalog; + // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 + // 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 + // 503 + MODEL_UNAVAILABLE_FOR_TIER(与单个模型不可用的 422 + MODEL_UNAVAILABLE 区分开)。 + let default_model_id = catalog + .default_model_id_for(access) + .ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))? + .to_string(); + let models = catalog + .available_models_for(access) + .map(|model| LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + }) + .collect(); + // 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。 + let unavailable_models = catalog + .unavailable_models_for(access) + .map(|(model, reason)| LlmUnavailableModel { + id: model.id.clone(), + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + reason, + }) + .collect(); + Ok(LlmModelsResponse { + default_model_id, + models, + unavailable_models, revision: catalog.revision, - } + }) } /// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」, @@ -262,6 +355,36 @@ async fn load_llm_catalog( crate::agc_models::load_catalog(state).await } +/// 全局目录 + 账号权限档;所有对外 LLM 路由都经它解析模型。 +/// +/// 两者相互独立,热路径上用 `join!` 并发发往 SpacetimeDB,避免串行叠加两次往返延迟。 +async fn load_owner_llm_catalog( + state: &AppState, + owner: &str, +) -> Result { + let (catalog, access) = tokio::join!( + load_llm_catalog(state, owner), + resolve_owner_agc_model_access(state, owner), + ); + Ok(OwnerAgcModelCatalog { + catalog: catalog?, + access: access?, + }) +} + +/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用是 422; +/// 「该档没有任何可用模型」是目录 / 档位配置问题,按 503 处理,和 `/api/llm/models` 列表端点一致。 +fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError { + let status = match error { + AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN, + AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY, + AgcModelResolveError::NoModelForTier => StatusCode::SERVICE_UNAVAILABLE, + }; + AppError::from_status(status) + .with_code(error.code()) + .with_message(error.message()) +} + /// Proxies the OpenAI-compatible Responses protocol for the LLM Router. /// /// The caller only presents the platform access token. The Router credential @@ -334,7 +457,7 @@ pub async fn proxy_llm_responses( .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == "agc"); - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; let requested_model = if agc_client { @@ -343,13 +466,9 @@ pub async fn proxy_llm_responses( None }; let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -550,21 +669,17 @@ pub async fn proxy_llm_messages( ] { object.remove(field); } - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补 // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识; - // 目录外取值仍由 `resolve_requested` 拒绝。 + // 目录外取值与档位不足都由 `resolve_requested_for` 拒绝。 let requested_model = requested_model.as_deref(); let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); // 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。 object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -1400,12 +1515,20 @@ fn is_responses_terminal_sse_event(event: &str) -> bool { async fn resolve_llm_router_client( state: &AppState, owner_user_id: &str, -) -> Result<(platform_llm::LlmClient, String), String> { - let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?; - let catalog = load_llm_catalog(state, owner_user_id) +) -> Result<(platform_llm::LlmClient, String), AppError> { + let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id) .await - .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve_requested(None)?; + .map_err(|message| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) + })?; + // 目录读失败与会员权限读失败(503 + MODEL_ACCESS_UNAVAILABLE)都保留各自的类型化错误, + // 不再拍平成一句中文,与 `proxy_llm_responses` / `proxy_llm_messages` 的契约一致。 + let catalog = load_owner_llm_catalog(state, owner_user_id).await?; + // Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。 + let model = catalog + .catalog + .resolve_requested_for(None, catalog.access) + .map_err(agc_model_resolve_error)?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), @@ -1415,10 +1538,16 @@ async fn resolve_llm_router_client( state.config.llm_max_retries, state.config.llm_retry_backoff_ms, ) - .map_err(|error| error.to_string())?; + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("LLM Router 客户端配置非法:{error}")) + })?; platform_llm::LlmClient::new(config) .map(|client| (client, key_id)) - .map_err(|error| error.to_string()) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("创建 LLM Router 客户端失败:{error}")) + }) } async fn resolve_llm_router_credentials( diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs new file mode 100644 index 000000000..a1760483c --- /dev/null +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -0,0 +1,73 @@ +//! 账号会员档位 → AGC 模型权限档的解析(BFF 侧,带 IO)。 +//! +//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成 +//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。 +//! +//! LLM 路由对外错误码(四条互不重叠,客户端据此区分处理): +//! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。 +//! - `MODEL_UNAVAILABLE`(422):模型不在目录或已停用。 +//! - `MODEL_UNAVAILABLE_FOR_TIER`(503):该档在目录里没有任何可用模型,属目录 / 档位配置问题, +//! 列表端点与代理端点统一按 503 上报,与单个模型不可用(422)区分。 +//! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。 +//! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。 +//! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。 + +use axum::http::StatusCode; +use module_runtime::{AgcModelAccess, AgcModelCatalog}; + +use crate::{http_error::AppError, state::AppState}; + +/// 读取账号权限档失败(基础设施)时的专用错误码,与档位不足 / 模型不可用区分。 +pub(crate) const MODEL_ACCESS_UNAVAILABLE: &str = "MODEL_ACCESS_UNAVAILABLE"; + +/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。 +pub(crate) struct OwnerAgcModelCatalog { + pub(crate) catalog: AgcModelCatalog, + pub(crate) access: AgcModelAccess, +} + +/// 读链路失败时的统一错误:503 + [`MODEL_ACCESS_UNAVAILABLE`]。 +/// +/// 独立成函数是为了让「基础设施失败」这件事可被单测直接断言,而不是散落的构造。 +pub(crate) fn model_access_unavailable_error() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code(MODEL_ACCESS_UNAVAILABLE) + .with_message("会员权限档暂不可用,请稍后重试") +} + +/// 解析账号的 AGC 模型权限档。 +/// +/// 走专用只读 procedure `get_profile_agc_model_access`:它只读会员账期投影与档位目录, +/// 不触发换期 / 免费点刷新,适合 LLM 代理热路径。 +/// +/// 成功返回档位;读链路失败返回 503 + [`MODEL_ACCESS_UNAVAILABLE`],由路由层透传,不再静默降级成 `Basic`。 +/// 缺会员行 / 缺档位目录行仍由 module 内失败关闭到 `Basic` 并返回成功。 +pub(crate) async fn resolve_owner_agc_model_access( + state: &AppState, + owner_user_id: &str, +) -> Result { + #[cfg(test)] + if super::test_provisioned_router_credentials() + .lock() + .expect("fixture lock") + .contains_key(owner_user_id) + { + // 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。 + return Ok(AgcModelAccess::Full); + } + match state + .spacetime_client() + .get_profile_agc_model_access(owner_user_id.to_string()) + .await + { + Ok(record) => Ok(AgcModelAccess::from(record.model_access)), + Err(error) => { + tracing::error!( + user_id = %owner_user_id, + error = %error, + "读取会员档位失败,AGC 模型权限按 503 上报" + ); + Err(model_access_unavailable_error()) + } + } +} diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index 8e40ebb3c..7b02bfc46 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -29,8 +29,9 @@ use crate::{ runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, 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_list_profile_membership_plans, admin_list_profile_recharge_products, + admin_list_profile_redeem_codes, admin_list_profile_task_configs, + admin_upsert_profile_invite_code, admin_upsert_profile_membership_plan, admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, admin_upsert_profile_wallet_config, }, @@ -172,6 +173,10 @@ pub fn router(state: AppState) -> Router { "/admin/api/profile/recharge-products", get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product), ), + ( + "/admin/api/profile/membership-plans", + get(admin_list_profile_membership_plans).post(admin_upsert_profile_membership_plan), + ), ( "/admin/api/profile/recharge-orders", get(admin_list_recharge_orders), @@ -279,6 +284,7 @@ mod route_contract_tests { ("/admin/api/profile/tasks/disable", &["POST"]), ("/admin/api/profile/wallet-config", &["GET", "POST"]), ("/admin/api/profile/recharge-products", &["GET", "POST"]), + ("/admin/api/profile/membership-plans", &["GET", "POST"]), ("/admin/api/profile/recharge-orders", &["GET"]), ("/admin/api/profile/recharge-refunds/preview", &["POST"]), ("/admin/api/profile/recharge-refunds/execute", &["POST"]), diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index e558fe7a1..8afde1908 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -17,8 +17,9 @@ use crate::{ runtime_profile::{ claim_profile_task_reward, confirm_wechat_profile_recharge_order, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, - get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, - get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, + get_profile_membership_upgrade_quote, get_profile_recharge_center, + get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, + redeem_profile_referral_invite_code, redeem_profile_reward_code, stream_wechat_profile_recharge_order_events, submit_profile_feedback, }, state::AppState, @@ -100,6 +101,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/membership/upgrade-quote", + post(get_profile_membership_upgrade_quote).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/recharge/orders", post(create_profile_recharge_order).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index fcd397610..dab2a357f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -9,23 +9,23 @@ use axum::{ }; use hmac::{Hmac, Mac}; use module_runtime::{ - AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, + AnalyticsGranularity, MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, RuntimeProfileDailyFreePointsRecord, RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, - RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, - RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipPlanRecord, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, is_unlimited_concurrency, }; use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder}; use serde::{Deserialize, Serialize}; @@ -35,14 +35,12 @@ use shared_contracts::runtime::{ ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, - AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, - AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest, - AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, + AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertProfileWalletConfigRequest, AnalyticsBucketMetricResponse, + AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, - PROFILE_MEMBERSHIP_TIER_BASIC, PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, - PROFILE_MEMBERSHIP_TIER_PRO, PROFILE_MEMBERSHIP_TIER_SEASON, PROFILE_MEMBERSHIP_TIER_STARTER, - PROFILE_MEMBERSHIP_TIER_ULTIMATE, 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, @@ -56,6 +54,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_GRANT, PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT, PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, @@ -65,7 +64,9 @@ use shared_contracts::runtime::{ ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfileMudPointBalanceResponse, + ProfileMembershipPlanAdminListResponse, ProfileMembershipPlanResponse, + ProfileMembershipResponse, ProfileMembershipUpgradeQuoteRequest, + ProfileMembershipUpgradeQuoteResponse, ProfileMudPointBalanceResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, @@ -215,6 +216,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY } + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT + } // 游戏买断制购买:每账号每游戏一次扣费流水。 RuntimeProfileWalletLedgerSourceType::GamePurchase => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_GAME_PURCHASE @@ -245,6 +249,44 @@ pub async fn get_profile_recharge_center( )) } +/// 补差升级报价:只读。入参是目标档位,周期沿用当前会员周期(有效期内不支持月年互换)。 +pub async fn get_profile_membership_upgrade_quote( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let target_plan = payload.target_plan.into(); + let quote = state + .spacetime_client() + .get_profile_membership_upgrade_quote(user_id, target_plan) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + ProfileMembershipUpgradeQuoteResponse { + current_plan: quote.current_plan.into(), + target_plan: quote.target_plan.into(), + cycle_kind: quote.cycle_kind.into(), + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at: Some(quote.expires_at), + cycle_resets_at: Some(quote.cycle_resets_at), + remaining_full_months: quote.remaining_full_months, + }, + )) +} + pub async fn create_profile_recharge_order( State(state): State, Extension(request_context): Extension, @@ -259,15 +301,9 @@ pub async fn create_profile_recharge_order( .map_err(|error| runtime_profile_error_response(&request_context, error))?; validate_recharge_payment_channel(&state, &payment_channel) .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let created_at_micros = current_unix_micros(); let (center, order) = state .spacetime_client() - .create_profile_recharge_order( - user_id, - payload.product_id, - payment_channel.clone(), - created_at_micros, - ) + .create_profile_recharge_order(user_id, payload.product_id, payment_channel.clone()) .await .map_err(|error| { runtime_profile_error_response( @@ -1090,12 +1126,6 @@ pub async fn admin_upsert_profile_recharge_product( 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() @@ -1106,15 +1136,8 @@ pub async fn admin_upsert_profile_recharge_product( 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.membership_period_points, - payload.membership_period_days, - payload.membership_queue_limit, - payload.membership_discount_bps, payload.enabled, payload.sort_order.unwrap_or(10), updated_at_micros as i64, @@ -1133,6 +1156,72 @@ pub async fn admin_upsert_profile_recharge_product( )) } +pub async fn admin_list_profile_membership_plans( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, +) -> Result, Response> { + let entries = state + .spacetime_client() + .admin_list_profile_membership_plans(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), + ProfileMembershipPlanAdminListResponse { + entries: entries + .into_iter() + .map(build_profile_membership_plan_response) + .collect(), + unlimited_concurrency_threshold: MEMBERSHIP_UNLIMITED_CONCURRENCY, + }, + )) +} + +/// 后台按档位改会员价格与权益。`rank` 不在请求里:升级比较基准由代码内目录决定。 +pub async fn admin_upsert_profile_membership_plan( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let plan = payload.plan.into(); + let model_access = payload.model_access.into(); + let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let record = state + .spacetime_client() + .admin_upsert_profile_membership_plan( + admin.session().subject.clone(), + plan, + payload.title, + payload.month_price_cents, + payload.year_price_cents, + payload.period_points, + model_access, + payload.concurrent_job_limit, + payload.enabled, + 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_membership_plan_response(record), + )) +} + pub async fn admin_list_profile_redeem_codes( State(state): State, Extension(request_context): Extension, @@ -1377,40 +1466,29 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let is_redeem_code_domain_error = matches!( - &error, - SpacetimeClientError::Procedure(message) - if is_runtime_profile_redeem_code_domain_error(message) - ); - let (status, provider) = match &error { - SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), - SpacetimeClientError::Procedure(_) if is_redeem_code_domain_error => { - (StatusCode::BAD_REQUEST, "runtime-profile") + match &error { + // 带 typed error_code 的 profile procedure(会员、兑换码核销、后台兑换码管理、后台邀请码 + // 管理)业务拒绝:机器码与人读文案都取自 typed 变体,HTTP 状态、错误码和 message 全部 + // 结构化透传,不做任何字符串匹配或解析;其余 procedure 仍按裸字符串落下面的 502。 + SpacetimeClientError::ProcedureRejected { code, message } => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_code(code.as_str()) + .with_message(message.clone()) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })) } - _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), - }; - - let app_error = AppError::from_status(status).with_details(json!({ - "provider": provider, - "message": error.to_string(), - })); - if is_redeem_code_domain_error { - return app_error.with_message(error.to_string()); + SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": "runtime-profile", + "message": message, + })), + _ => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })), } - app_error -} - -fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { - matches!( - message, - "兑换码不存在" - | "兑换码已停用" - | "兑换码未生效" - | "兑换码已过期" - | "兑换次数已用完" - | "该兑换码不适用于当前账号" - | "兑换码奖励无效" - ) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -1672,23 +1750,34 @@ fn build_wechat_virtual_pay_params( order: &RuntimeProfileRechargeOrderRecord, openid: &str, ) -> Result { - let product = match order.kind { + let buy_quantity = match order.kind { RuntimeProfileRechargeProductKind::Points => center .point_products .iter() .find(|item| item.product_id == order.product_id) + .map(|item| item.points_amount) .ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST) .with_message("当前充值商品不存在,请刷新后再试") })?, - RuntimeProfileRechargeProductKind::Membership => center - .membership_products - .iter() - .find(|item| item.product_id == order.product_id) - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("当前充值商品不存在,请刷新后再试") - })?, + RuntimeProfileRechargeProductKind::Membership => { + // 会员商品不在商品配置表里:确认档位仍可售(在售且非 Normal 占位)即可,购买数量固定为 1。 + let (plan, _) = + module_runtime::parse_runtime_profile_membership_product_id(&order.product_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前会员套餐不存在,请刷新后再试") + })?; + if !center + .membership_plans + .iter() + .any(|item| item.plan == plan && item.enabled && item.plan.is_purchasable()) + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前会员套餐不存在,请刷新后再试")); + } + 1 + } }; let identity = state .wechat_auth_service() @@ -1716,10 +1805,6 @@ fn build_wechat_virtual_pay_params( RuntimeProfileRechargeProductKind::Points => "short_series_coin", RuntimeProfileRechargeProductKind::Membership => "short_series_goods", }; - let buy_quantity = match product.kind { - RuntimeProfileRechargeProductKind::Points => product.points_amount, - RuntimeProfileRechargeProductKind::Membership => 1, - }; let mut sign_data = serde_json::json!({ "offerId": offer_id, "buyQuantity": buy_quantity, @@ -1833,11 +1918,10 @@ fn build_profile_recharge_center_response( ) -> ProfileRechargeCenterResponse { let mud_point_balance = build_profile_mud_point_balance_response(&record); ProfileRechargeCenterResponse { - wallet_balance: record.wallet_balance, mud_point_balance, membership: ProfileMembershipResponse { - status: record.membership.status.as_str().to_string(), - tier: record.membership.tier.as_str().to_string(), + status: record.membership.status.into(), + plan: record.membership.plan.into(), started_at: record.membership.started_at, expires_at: record.membership.expires_at, updated_at: record.membership.updated_at, @@ -1845,22 +1929,19 @@ fn build_profile_recharge_center_response( cycle_resets_at: record.membership.cycle_resets_at, cycle_granted_points: record.membership.cycle_granted_points, cycle_remaining_points: record.membership.cycle_remaining_points, - cycle_period_days: record.membership.cycle_period_days, + cycle_index: record.membership.cycle_index, + cycle_count: record.membership.cycle_count, + cycle_kind: record.membership.cycle_kind.into(), }, point_products: record .point_products .into_iter() .map(build_profile_recharge_product_response) .collect(), - membership_products: record - .membership_products + membership_plans: record + .membership_plans .into_iter() - .map(build_profile_recharge_product_response) - .collect(), - benefits: record - .benefits - .into_iter() - .map(build_profile_membership_benefit_response) + .map(build_profile_membership_plan_response) .collect(), latest_order: record .latest_order @@ -1874,12 +1955,13 @@ fn build_profile_mud_point_balance_response( record: &RuntimeProfileRechargeCenterRecord, ) -> ProfileMudPointBalanceResponse { let daily_free_points = record.daily_free_points.remaining_points; - let limited_points = record.membership.cycle_remaining_points; + let monthly_points = record.membership.cycle_remaining_points; + // 永久泥点 = 单一总额 − 每日免费 − 月度;存储层仍是 `wallet_balance` 单一权威。 let permanent_points = record .wallet_balance .saturating_sub(daily_free_points) - .saturating_sub(limited_points); - let limited_expires_at = (limited_points > 0) + .saturating_sub(monthly_points); + let monthly_resets_at = (monthly_points > 0) .then(|| { record .membership @@ -1890,13 +1972,12 @@ fn build_profile_mud_point_balance_response( .flatten(); ProfileMudPointBalanceResponse { - total_points: record.wallet_balance, - permanent_points, - limited_points, - limited_expires_at, daily_free_points, daily_free_reset_points: record.daily_free_points.reset_points, daily_free_resets_at: record.daily_free_points.resets_at.clone(), + monthly_points, + monthly_resets_at, + permanent_points, } } @@ -1925,27 +2006,26 @@ fn build_profile_recharge_product_response( duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, - tier: record.tier.as_str().to_string(), - membership_period_points: record.membership_period_points, - membership_period_days: record.membership_period_days, - membership_queue_limit: record.membership_queue_limit, - membership_discount_bps: record.membership_discount_bps, } } -fn build_profile_membership_benefit_response( - record: RuntimeProfileMembershipBenefitRecord, -) -> ProfileMembershipBenefitResponse { - ProfileMembershipBenefitResponse { - benefit_name: record.benefit_name, - normal_value: record.normal_value, - month_value: record.month_value, - season_value: record.season_value, - year_value: record.year_value, - starter_value: record.starter_value, - basic_value: record.basic_value, - pro_value: record.pro_value, - ultimate_value: record.ultimate_value, +/// 会员档位目录 → 对外响应。`recommended` 与 `unlimited_concurrency` 由后端派生, +/// 前端只消费标志,不再各自硬编码推荐档位或并发哨兵 `128`。 +fn build_profile_membership_plan_response( + record: RuntimeProfileMembershipPlanRecord, +) -> ProfileMembershipPlanResponse { + ProfileMembershipPlanResponse { + plan: record.plan.into(), + title: record.title, + rank: record.rank, + month_price_cents: record.month_price_cents, + year_price_cents: record.year_price_cents, + period_points: record.period_points, + model_access: record.model_access.into(), + concurrent_job_limit: record.concurrent_job_limit, + recommended: record.plan == PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + unlimited_concurrency: is_unlimited_concurrency(record.concurrent_job_limit), + enabled: record.enabled, } } @@ -2198,11 +2278,6 @@ fn build_profile_recharge_product_config_admin_response( duration_days: record.duration_days, badge_label: record.badge_label, description: record.description, - tier: format_profile_membership_tier(record.tier).to_string(), - membership_period_points: record.membership_period_points, - membership_period_days: record.membership_period_days, - membership_queue_limit: record.membership_queue_limit, - membership_discount_bps: record.membership_discount_bps, enabled: record.enabled, sort_order: record.sort_order, created_by: record.created_by, @@ -2305,20 +2380,7 @@ fn parse_profile_recharge_product_kind( } } -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), - PROFILE_MEMBERSHIP_TIER_STARTER => Ok(RuntimeProfileMembershipTier::Starter), - PROFILE_MEMBERSHIP_TIER_BASIC => Ok(RuntimeProfileMembershipTier::Basic), - PROFILE_MEMBERSHIP_TIER_PRO => Ok(RuntimeProfileMembershipTier::Pro), - PROFILE_MEMBERSHIP_TIER_ULTIMATE => Ok(RuntimeProfileMembershipTier::Ultimate), - _ => Err("会员档位无效".to_string()), - } -} - +/// 会员档位 / 模型权限 / 周期改由 typed enum 在反序列化阶段校验,未知 token 直接 4xx。 fn parse_tracking_scope_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), @@ -2362,19 +2424,6 @@ fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) } } -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, - RuntimeProfileMembershipTier::Starter => PROFILE_MEMBERSHIP_TIER_STARTER, - RuntimeProfileMembershipTier::Basic => PROFILE_MEMBERSHIP_TIER_BASIC, - RuntimeProfileMembershipTier::Pro => PROFILE_MEMBERSHIP_TIER_PRO, - RuntimeProfileMembershipTier::Ultimate => PROFILE_MEMBERSHIP_TIER_ULTIMATE, - } -} - fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str { match scope_kind { RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE, @@ -2441,11 +2490,15 @@ mod tests { use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile}; use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - RuntimeProfileDailyFreePointsRecord, RuntimeProfileMembershipRecord, - RuntimeProfileMembershipStatus, RuntimeProfileMembershipTier, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeProfileDailyFreePointsRecord, RuntimeProfileInviteCodeAdminErrorCode, + RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipErrorCode, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipRecord, + RuntimeProfileMembershipStatus, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeAdminErrorCode, RuntimeProfileRewardCodeRedeemErrorCode, + RuntimeProfileWalletLedgerSourceType, }; use super::{ @@ -2461,13 +2514,44 @@ mod tests { #[test] fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() { - for message in ["兑换码未生效", "兑换码已过期"] { - let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( - message.to_string(), - )); + // 兑换码拒绝与会员链路同构:机器码从 typed 变体取,文案只做人读展示,不参与分类。 + for (code, message) in [ + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + "兑换码未生效", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + "兑换码已过期", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + "兑换码已停用", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + "兑换次数已用完", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + "该兑换码不适用于当前账号", + ), + ( + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + "兑换码奖励无效", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); - assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); - assert_eq!(error.code(), "BAD_REQUEST"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); assert_eq!(error.message(), message); assert_eq!( error @@ -2489,6 +2573,123 @@ mod tests { .and_then(Value::as_str), Some("spacetimedb") ); + + // 旧实现按中文文案把「兑换码已过期」判成 400;typed 改造后同样的裸字符串必须落到 502。 + let legacy_string_error = map_runtime_profile_client_error( + SpacetimeClientError::Procedure("兑换码已过期".into()), + ); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); + } + + #[test] + fn redeem_code_admin_rejections_map_to_bad_request() { + // 后台兑换码管理的业务拒绝同样随 procedure 透传 error_code;「停用不存在的码」这类 + // 拒绝必须落 400,不能因为缺少机器码被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "not_found", + "兑换码不存在", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + "invalid_field", + "兑换码奖励点数必须大于 0", + ), + ( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "allowed_users_required", + "私有兑换码必须指定可兑换用户", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), expected_code, "{message}"); + assert_eq!(error.message(), message); + } + } + + #[test] + fn invite_code_admin_rejections_map_to_bad_request() { + // 后台邀请码管理与后台兑换码管理同构:「邀请码已被占用」/入参非法必须落 400 并带机器码, + // 不能因为缺 error_code 被当成 SpacetimeDB 基础设施故障落 502。 + for (code, expected_code, message) in [ + ( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + "invalid_field", + "邀请码不能为空", + ), + ( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "in_use", + "邀请码已被其他用户占用", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), expected_code, "{message}"); + assert_eq!(error.message(), message); + } + } + + #[test] + fn membership_business_rejections_map_to_bad_request() { + // 中文注释:会员业务拒绝由 procedure 透传机器可读 error_code,统一按 400 返回; + // 中文文案不再参与分类,普通 Procedure 字符串错误保持 502。 + for (code, message) in [ + ( + RuntimeProfileMembershipErrorCode::UpgradeRejected, + "会员升级被拒绝: cycle_kind_change_unsupported", + ), + ( + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, + "会员商品 product_id 无法解析: membership-pro-yearly", + ), + ( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + "充值商品类型无效:会员商品已迁移到会员档位目录", + ), + ( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + "会员目录缺少档位 pro", + ), + // 目录价格 / 每期泥点倒挂属后端配置错误,与 UpgradeRejected 分开暴露机器码。 + ( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + "会员升级被拒绝: catalog_pricing_inverted", + ), + ] { + let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { + code: code.into(), + message: message.to_string(), + }); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}"); + assert_eq!(error.code(), code.as_str(), "{message}"); + assert_eq!(error.message(), message); + assert_eq!( + error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("runtime-profile") + ); + } + + // 中文注释:删掉字符串前缀分类后,同样的文案若没有 error_code 必须落到 502。 + let legacy_string_error = + map_runtime_profile_client_error(SpacetimeClientError::Procedure( + "会员升级被拒绝: cycle_kind_change_unsupported".to_string(), + )); + assert_eq!(legacy_string_error.status_code(), StatusCode::BAD_GATEWAY); } #[test] @@ -2553,7 +2754,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: "user-1".to_string(), status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Starter, + plan: RuntimeProfileMembershipPlan::Starter, started_at: Some("2026-07-01T00:00:00Z".to_string()), started_at_micros: Some(1_783_036_800_000_000), expires_at: Some("2026-08-01T00:00:00Z".to_string()), @@ -2566,11 +2767,12 @@ mod tests { cycle_resets_at_micros: Some(1_784_073_600_000_000), cycle_granted_points: 80, cycle_remaining_points: 80, - cycle_period_days: 7, + cycle_index: 1, + cycle_count: 1, + cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -2578,13 +2780,12 @@ mod tests { let balance = build_profile_mud_point_balance_response(&record); - assert_eq!(balance.total_points, 200); assert_eq!(balance.permanent_points, 100); - assert_eq!(balance.limited_points, 80); + assert_eq!(balance.monthly_points, 80); assert_eq!(balance.daily_free_points, 20); assert_eq!(balance.daily_free_reset_points, 35); assert_eq!( - balance.limited_expires_at.as_deref(), + balance.monthly_resets_at.as_deref(), Some("2026-07-15T00:00:00Z"), ); assert_eq!(balance.daily_free_resets_at, "2026-07-12T16:00:00Z"); @@ -2640,6 +2841,12 @@ mod tests { ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT + ); } #[test] @@ -3399,10 +3606,10 @@ mod tests { let order = RuntimeProfileRechargeOrderRecord { order_id: "memberorder01".to_string(), user_id: user_id.clone(), - product_id: "member_month".to_string(), - product_title: "月卡".to_string(), + product_id: "membership-starter-yearly".to_string(), + product_title: "Starter·年付".to_string(), kind: RuntimeProfileRechargeProductKind::Membership, - amount_cents: 2800, + amount_cents: 39000, status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL .to_string(), @@ -3428,7 +3635,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3441,26 +3648,23 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![], - membership_products: vec![RuntimeProfileRechargeProductRecord { - product_id: "member_month".to_string(), - title: "月卡".to_string(), - price_cents: 2800, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, - bonus_points: 0, - duration_days: 30, - badge_label: String::new(), - description: "30天会员".to_string(), - tier: RuntimeProfileMembershipTier::Month, - membership_period_points: 0, - membership_period_days: 30, - membership_queue_limit: 0, - membership_discount_bps: 0, + membership_plans: vec![RuntimeProfileMembershipPlanRecord { + plan: RuntimeProfileMembershipPlan::Starter, + title: "Starter".to_string(), + rank: 1, + month_price_cents: 3900, + year_price_cents: 39000, + period_points: 400, + model_access: RuntimeProfileMembershipModelAccess::Basic, + concurrent_job_limit: 1, + enabled: true, }], - benefits: vec![], + latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -3481,8 +3685,8 @@ mod tests { assert_eq!(params.mode, "short_series_goods"); assert_eq!(sign_data["buyQuantity"], 1); assert_eq!(sign_data["offerId"], "offer-1"); - assert_eq!(sign_data["productId"], "member_month"); - assert_eq!(sign_data["goodsPrice"], 2800); + assert_eq!(sign_data["productId"], "membership-starter-yearly"); + assert_eq!(sign_data["goodsPrice"], 39000); assert_eq!(sign_data["outTradeNo"], "memberorder01"); assert_eq!(attach["paymentChannel"], "wechat_mp_virtual"); assert!(!params.pay_sig.is_empty()); @@ -3544,7 +3748,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3557,7 +3761,9 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![RuntimeProfileRechargeProductRecord { product_id: "points_60".to_string(), @@ -3569,14 +3775,8 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: "60+60泥点".to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, }], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: true, daily_free_points: daily_free_points_record(), @@ -3604,7 +3804,7 @@ mod tests { } #[tokio::test] - async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() { + async fn wechat_virtual_pay_params_accept_catalog_membership_product_ids() { let state = seed_authenticated_state_with_config(AppConfig { wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()), wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()), @@ -3627,12 +3827,12 @@ mod tests { .expect("wechat identity should seed"); let user_id = wechat_login.user.id.clone(); let order = RuntimeProfileRechargeOrderRecord { - order_id: "item01order01".to_string(), + order_id: "starterorder01".to_string(), user_id: user_id.clone(), - product_id: "item01".to_string(), - product_title: "测试道具".to_string(), + product_id: "membership-starter-monthly".to_string(), + product_title: "Starter·月付".to_string(), kind: RuntimeProfileRechargeProductKind::Membership, - amount_cents: 100, + amount_cents: 3900, status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL .to_string(), @@ -3658,7 +3858,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3671,26 +3871,23 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![], - membership_products: vec![RuntimeProfileRechargeProductRecord { - product_id: "item01".to_string(), - title: "测试道具".to_string(), - price_cents: 100, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, - bonus_points: 0, - duration_days: 30, - badge_label: String::new(), - description: "30天会员".to_string(), - tier: RuntimeProfileMembershipTier::Month, - membership_period_points: 0, - membership_period_days: 30, - membership_queue_limit: 0, - membership_discount_bps: 0, + membership_plans: vec![RuntimeProfileMembershipPlanRecord { + plan: RuntimeProfileMembershipPlan::Starter, + title: "Starter".to_string(), + rank: 1, + month_price_cents: 3900, + year_price_cents: 39000, + period_points: 400, + model_access: RuntimeProfileMembershipModelAccess::Basic, + concurrent_job_limit: 1, + enabled: true, }], - benefits: vec![], + latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), @@ -3702,9 +3899,9 @@ mod tests { serde_json::from_str(¶ms.sign_data).expect("sign data should be valid json"); assert_eq!(params.mode, "short_series_goods"); - assert_eq!(sign_data["productId"], "item01"); - assert_eq!(sign_data["goodsPrice"], 100); - assert_eq!(sign_data["outTradeNo"], "item01order01"); + assert_eq!(sign_data["productId"], "membership-starter-monthly"); + assert_eq!(sign_data["goodsPrice"], 3900); + assert_eq!(sign_data["outTradeNo"], "starterorder01"); } #[test] @@ -3853,7 +4050,7 @@ mod tests { membership: RuntimeProfileMembershipRecord { user_id: user_id.clone(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at: None, started_at_micros: None, expires_at: None, @@ -3866,7 +4063,9 @@ mod tests { cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, }, point_products: vec![RuntimeProfileRechargeProductRecord { product_id: "points_60".to_string(), @@ -3878,14 +4077,8 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: "60+60泥点".to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, }], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: daily_free_points_record(), diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs new file mode 100644 index 000000000..5e81a77fb --- /dev/null +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -0,0 +1,401 @@ +//! AGC 模型权限档与「按会员档位过滤模型目录」的判定(纯函数,无 IO)。 +//! +//! 模型目录是全局的,但每个模型带一个权限档;会员档位(`profile_membership_plan.model_access`) +//! 决定账号能看到、能调用哪些模型。这里只做判定与解析,SpacetimeDB 读写留在 `spacetime-module`, +//! HTTP 错误映射留在 `api-server`。 +//! +//! 失败开放:模型权限档缺省 [`AgcModelAccess::Basic`],因此存量目录 JSON 缺字段、后台未人工标注 +//! `Full` 时都不会拦截任何请求;只有明确标了 `Full` 的模型才会对 `Basic` 档账号报越权。 +//! +//! 本文件把「档位可见集合」「档位默认模型」「请求侧解析」收在一处,调用方只接触 +//! [`AgcModelCatalog::resolve_requested_for`] 这类语义方法,不自己拼过滤条件。 + +use serde::{Deserialize, Serialize}; + +use crate::{ + AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess, +}; +use shared_contracts::llm_catalog::LlmModelUnavailableReason; + +/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 +pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; + +/// 所选模型不在目录或已停用时对外返回的错误码。 +pub const MODEL_UNAVAILABLE: &str = "MODEL_UNAVAILABLE"; + +/// 当前会员档位在目录里没有任何可用模型时对外返回的错误码。 +/// +/// 与 [`MODEL_UNAVAILABLE`] 区分:后者是「单个模型的问题」(422),本码是「目录 / 档位配置 +/// 问题」(503),修法不同,客户端只按 `error.code` 也能分辨。 +pub const MODEL_UNAVAILABLE_FOR_TIER: &str = "MODEL_UNAVAILABLE_FOR_TIER"; + +/// 单个模型要求的权限档。 +/// +/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理, +/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。未知取值同样 +/// 失败开放到 `Basic`(`#[serde(other)]`),避免将来新增档位时旧服务端整份目录解析失败、 +/// 静默回退到上游目录而丢掉人工标注。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgcModelAccess { + Full, + #[default] + #[serde(other)] + Basic, +} + +impl AgcModelAccess { + pub const fn as_str(self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "basic" => Some(Self::Basic), + "full" => Some(Self::Full), + _ => None, + } + } + + /// 当前账号档位是否覆盖模型要求的档位:`Full` 覆盖全部,`Basic` 只覆盖基础。 + pub const fn allows(self, required: Self) -> bool { + matches!( + (self, required), + (Self::Full, _) | (Self::Basic, Self::Basic) + ) + } +} + +impl From for AgcModelAccess { + fn from(value: RuntimeProfileMembershipModelAccess) -> Self { + match value { + RuntimeProfileMembershipModelAccess::Basic => Self::Basic, + RuntimeProfileMembershipModelAccess::Full => Self::Full, + } + } +} + +impl From for AgcModelAccess { + fn from(value: shared_contracts::runtime::ProfileMembershipModelAccessToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic => Self::Basic, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Full => Self::Full, + } + } +} + +impl From for shared_contracts::runtime::ProfileMembershipModelAccessToken { + fn from(value: AgcModelAccess) -> Self { + match value { + AgcModelAccess::Basic => Self::Basic, + AgcModelAccess::Full => Self::Full, + } + } +} + +/// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgcModelResolveError { + /// 标识不在目录、模型已停用。 + Unavailable, + /// 模型存在且启用,但当前会员档位不够(越权)。 + NotAvailableForPlan, + /// 该会员档位在目录里没有任何可用模型:属于目录 / 档位配置问题,不是某个模型的问题。 + NoModelForTier, +} + +impl AgcModelResolveError { + pub const fn code(self) -> &'static str { + match self { + Self::Unavailable => MODEL_UNAVAILABLE, + Self::NoModelForTier => MODEL_UNAVAILABLE_FOR_TIER, + Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN, + } + } + + pub const fn message(self) -> &'static str { + match self { + Self::Unavailable => "所选模型不可用,请刷新模型列表", + Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型", + Self::NoModelForTier => "当前会员档位没有可用模型", + } + } +} + +impl AgcModel { + /// 该模型对给定账号档位是否可见 / 可调用(既要启用,也要档位足够)。 + pub fn is_available_for(&self, access: AgcModelAccess) -> bool { + self.enabled && access.allows(self.access) + } +} + +impl AgcModelCatalog { + /// 该档位可见的模型,保持目录顺序。 + pub fn available_models_for(&self, access: AgcModelAccess) -> impl Iterator { + self.models + .iter() + .filter(move |model| model.is_available_for(access)) + } + + /// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。 + /// + /// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且 + /// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标 + /// `plan_required` 误导用户。 + pub fn unavailable_models_for( + &self, + access: AgcModelAccess, + ) -> impl Iterator { + self.models.iter().filter_map(move |model| { + if !model.enabled { + Some((model, LlmModelUnavailableReason::Disabled)) + } else if !access.allows(model.access) { + Some((model, LlmModelUnavailableReason::PlanRequired)) + } else { + None + } + }) + } + + /// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时 + /// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。 + pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> { + let configured = self + .models + .iter() + .find(|model| model.id == self.default_model_id) + .filter(|model| model.is_available_for(access)); + if configured.is_some() { + return Some(self.default_model_id.as_str()); + } + self.available_models_for(access) + .map(|model| model.id.as_str()) + .next() + } + + /// 请求侧解析(带档位)。 + /// + /// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。 + /// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`], + /// **不静默回退**成其它模型;该档没有任何可用模型 → [`AgcModelResolveError::NoModelForTier`]。 + pub fn resolve_requested_for( + &self, + requested: Option<&str>, + access: AgcModelAccess, + ) -> Result<&str, AgcModelResolveError> { + let requested = requested + .map(str::trim) + .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); + match requested { + Some(id) => match self.models.iter().find(|model| model.id == id) { + Some(model) if !model.enabled => Err(AgcModelResolveError::Unavailable), + Some(model) if access.allows(model.access) => Ok(model.model_id.as_str()), + Some(_) => Err(AgcModelResolveError::NotAvailableForPlan), + None => Err(AgcModelResolveError::Unavailable), + }, + None => { + let id = self + .default_model_id_for(access) + .ok_or(AgcModelResolveError::NoModelForTier)?; + self.models + .iter() + .find(|model| model.id == id && model.is_available_for(access)) + .map(|model| model.model_id.as_str()) + .ok_or(AgcModelResolveError::NoModelForTier) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn catalog_from_ids(ids: &[&str], default_index: usize) -> AgcModelCatalog { + let mut catalog = + AgcModelCatalog::from_upstream_models(ids.iter().map(|id| id.to_string()), 0) + .expect("目录应从上游模型名构建"); + let default_id = catalog.models[default_index].id.clone(); + catalog.default_model_id = default_id; + catalog + } + + fn mark_full(catalog: &mut AgcModelCatalog, id: &str) { + let model = catalog + .models + .iter_mut() + .find(|model| model.id == id) + .expect("测试模型应存在"); + model.access = AgcModelAccess::Full; + } + + #[test] + fn access_defaults_to_basic_and_parses_strictly() { + assert_eq!(AgcModelAccess::default(), AgcModelAccess::Basic); + assert_eq!(AgcModelAccess::parse("basic"), Some(AgcModelAccess::Basic)); + assert_eq!(AgcModelAccess::parse("full"), Some(AgcModelAccess::Full)); + assert_eq!(AgcModelAccess::parse("Full"), None); + assert_eq!(AgcModelAccess::parse(""), None); + } + + #[test] + fn full_plan_covers_every_model_and_basic_plan_only_basic() { + assert!(AgcModelAccess::Full.allows(AgcModelAccess::Basic)); + assert!(AgcModelAccess::Full.allows(AgcModelAccess::Full)); + assert!(AgcModelAccess::Basic.allows(AgcModelAccess::Basic)); + assert!(!AgcModelAccess::Basic.allows(AgcModelAccess::Full)); + } + + #[test] + fn legacy_catalog_json_defaults_missing_access_to_basic() { + // 存量目录 JSON 没有 access 字段:按 Basic 解析,失败开放。 + let catalog: AgcModelCatalog = serde_json::from_str( + r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex"}]}"#, + ) + .expect("存量目录应可读"); + assert_eq!(catalog.models[0].access, AgcModelAccess::Basic); + } + + #[test] + fn unknown_catalog_json_access_fails_open_to_basic() { + // 中文注释:未知 access 取值(例如将来新增档位)不能让整份目录解析失败、 + // 静默回退上游目录,应失败开放到 Basic。 + let catalog: AgcModelCatalog = serde_json::from_str( + r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex","access":"premium"}]}"#, + ) + .expect("未知 access 应失败开放到 Basic"); + assert_eq!(catalog.models[0].access, AgcModelAccess::Basic); + } + + #[test] + fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1); + mark_full(&mut catalog, "model-b"); + + let visible = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(visible, vec!["model-a", "model-c"]); + + // 登记的默认项 model-b 是 Full,Basic 档回退到目录顺序里第一个可用项。 + assert_eq!( + catalog.default_model_id_for(AgcModelAccess::Basic), + Some("model-a") + ); + // Full 档保留登记的默认项。 + assert_eq!( + catalog.default_model_id_for(AgcModelAccess::Full), + Some("model-b") + ); + } + + #[test] + fn resolve_requested_for_distinguishes_unknown_from_out_of_plan() { + let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0); + mark_full(&mut catalog, "model-b"); + + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Basic), + Err(AgcModelResolveError::NotAvailableForPlan) + ); + assert_eq!( + catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic), + Err(AgcModelResolveError::Unavailable) + ); + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full), + Ok("model-b") + ); + // 未选择时用该档默认模型。 + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Ok("model-a") + ); + assert_eq!( + catalog.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Full), + Ok("model-a") + ); + } + + #[test] + fn disabled_model_is_unavailable_even_for_full_plan() { + let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0); + catalog.models[1].enabled = false; + assert_eq!( + catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full), + Err(AgcModelResolveError::Unavailable) + ); + } + + #[test] + fn plan_with_no_available_model_reports_no_model_for_tier() { + let mut catalog = catalog_from_ids(&["model-a"], 0); + mark_full(&mut catalog, "model-a"); + assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None); + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Err(AgcModelResolveError::NoModelForTier) + ); + // 显式点了目录外的模型仍是「该模型不可用」,不能和「整个档位没有模型」混为一谈。 + assert_eq!( + catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic), + Err(AgcModelResolveError::Unavailable) + ); + } + + #[test] + fn resolve_error_codes_are_stable() { + assert_eq!( + AgcModelResolveError::NotAvailableForPlan.code(), + MODEL_NOT_AVAILABLE_FOR_PLAN + ); + assert_eq!(AgcModelResolveError::Unavailable.code(), MODEL_UNAVAILABLE); + // 「该档没有可用模型」是目录 / 档位配置问题(503),与单个模型不可用(422)用不同的码, + // 让只按 error.code 分派的客户端也能区分。 + assert_eq!( + AgcModelResolveError::NoModelForTier.code(), + MODEL_UNAVAILABLE_FOR_TIER + ); + assert_ne!(MODEL_UNAVAILABLE, MODEL_UNAVAILABLE_FOR_TIER); + } + + #[test] + fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0); + mark_full(&mut catalog, "model-b"); + mark_full(&mut catalog, "model-c"); + catalog.models[2].enabled = false; + + let unavailable = catalog + .unavailable_models_for(AgcModelAccess::Basic) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + // model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。 + assert_eq!( + unavailable, + vec![ + ("model-b", LlmModelUnavailableReason::PlanRequired), + ("model-c", LlmModelUnavailableReason::Disabled), + ] + ); + + // Full 档只剩停用的 model-c;可用集合与不可用集合互补。 + let unavailable_full = catalog + .unavailable_models_for(AgcModelAccess::Full) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + assert_eq!( + unavailable_full, + vec![("model-c", LlmModelUnavailableReason::Disabled)] + ); + let available = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(available, vec!["model-a"]); + } +} diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index a6d406afc..b6aa1d710 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; +use crate::AgcModelAccess; + /// 目录 revision 乐观锁冲突。 pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT"; /// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。 @@ -12,99 +14,9 @@ pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; /// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgcAgentMode { - Codex, - /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; - /// 协议本身由 `AgcModel.protocol` 表达。 - #[serde(alias = "anthropic")] - Cc, -} - -impl Default for AgcAgentMode { - fn default() -> Self { - Self::Codex - } -} - -impl AgcAgentMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::Cc => "cc", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "codex" => Ok(Self::Codex), - "cc" => Ok(Self::Cc), - _ => Err("Agent 模式只允许 codex 或 cc".into()), - } - } - - /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: - /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 - /// Anthropic Messages。 - pub const fn supports(self, protocol: AgcModelProtocol) -> bool { - match self { - Self::Codex => matches!( - protocol, - AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat - ), - Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), - } - } - - /// 存量目录缺少协议字段时按执行器的默认线协议解释。 - pub const fn default_protocol(self) -> AgcModelProtocol { - match self { - Self::Codex => AgcModelProtocol::OpenAiResponses, - Self::Cc => AgcModelProtocol::Anthropic, - } - } -} - -/// 上游线协议,与执行器正交。 -/// -/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 -/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 -/// `/api/llm/anthropic/{*path}`。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgcModelProtocol { - #[serde(rename = "openai_responses")] - OpenAiResponses, - #[serde(rename = "openai_chat")] - OpenAiChat, - #[serde(rename = "anthropic")] - Anthropic, -} - -impl Default for AgcModelProtocol { - fn default() -> Self { - Self::OpenAiResponses - } -} - -impl AgcModelProtocol { - pub const fn as_str(self) -> &'static str { - match self { - Self::OpenAiResponses => "openai_responses", - Self::OpenAiChat => "openai_chat", - Self::Anthropic => "anthropic", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "openai_responses" => Ok(Self::OpenAiResponses), - "openai_chat" => Ok(Self::OpenAiChat), - "anthropic" => Ok(Self::Anthropic), - _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), - } - } -} +// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源); +// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。 +pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -120,6 +32,9 @@ pub struct AgcModel { /// 保存时必须与 `agent_mode` 组成合法组合。 #[serde(default)] pub protocol: Option, + /// 该模型要求的访问级别;缺省 `Basic`(失败开放),由人工在后台标 `Full`。 + #[serde(default)] + pub access: AgcModelAccess, } impl AgcModel { @@ -169,6 +84,7 @@ impl AgcModelCatalog { enabled: true, agent_mode: AgcAgentMode::default(), protocol: None, + access: AgcModelAccess::default(), }); } let default_model_id = entries @@ -228,26 +144,15 @@ impl AgcModelCatalog { )); } } - self.resolve(&self.default_model_id).map(|_| ()) - } - - pub fn resolve(&self, id: &str) -> Result<&str, String> { - self.models + // 默认项必须存在且启用,否则目录无法给出可用默认模型。 + if !self + .models .iter() - .find(|m| m.enabled && m.id == id) - .map(|m| m.model_id.as_str()) - .ok_or_else(|| "所选模型不可用,请刷新模型列表".into()) - } - - /// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。 - pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> { - let requested = requested - .map(str::trim) - .filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT); - match requested { - Some(id) => self.resolve(id), - None => self.resolve(&self.default_model_id), + .any(|model| model.enabled && model.id == self.default_model_id) + { + return Err("所选模型不可用,请刷新模型列表".into()); } + Ok(()) } } @@ -316,6 +221,7 @@ mod tests { enabled: true, agent_mode: AgcAgentMode::default(), protocol: None, + access: AgcModelAccess::default(), } } @@ -399,18 +305,30 @@ mod tests { let mut catalog = AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap(); assert!(catalog.validate().is_ok()); - assert_eq!(catalog.resolve("model-a").unwrap(), "model-a"); + assert_eq!( + catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic), + Ok("model-a") + ); // 客户端不能直接指定实际模型名,只能回传目录标识。 - assert!(catalog.resolve("model-c").is_err()); - assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a"); + assert_eq!( + catalog.resolve_requested_for(Some("model-c"), AgcModelAccess::Basic), + Err(crate::AgcModelResolveError::Unavailable) + ); + assert_eq!( + catalog.resolve_requested_for(None, AgcModelAccess::Basic), + Ok("model-a") + ); assert_eq!( catalog - .resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT)) + .resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Basic) .unwrap(), "model-a" ); catalog.models[0].enabled = false; - assert!(catalog.resolve("model-a").is_err()); + assert_eq!( + catalog.resolve_requested_for(Some("model-a"), AgcModelAccess::Basic), + Err(crate::AgcModelResolveError::Unavailable) + ); assert!(catalog.validate().is_err()); } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index a644d5166..3c0102b2a 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -8,6 +8,9 @@ use serde_json::Value; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use std::collections::{BTreeMap, BTreeSet}; +use crate::civil_calendar::{ + civil_from_days, days_from_civil, days_in_month, is_leap_year, weekday_from_date_key, +}; use crate::domain::*; use crate::errors::RuntimeProfileFieldError; use crate::format_utc_micros; @@ -1049,16 +1052,7 @@ pub fn build_runtime_profile_recharge_center_record( .into_iter() .map(build_runtime_profile_recharge_product_record) .collect(), - membership_products: snapshot - .membership_products - .into_iter() - .map(build_runtime_profile_recharge_product_record) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(build_runtime_profile_membership_benefit_record) - .collect(), + membership_plans: snapshot.membership_plans, latest_order: snapshot .latest_order .map(build_runtime_profile_recharge_order_record), @@ -1082,11 +1076,6 @@ pub fn build_runtime_profile_recharge_product_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1103,11 +1092,6 @@ pub fn build_runtime_profile_recharge_product_config_record( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: snapshot.tier, - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1119,29 +1103,13 @@ pub fn build_runtime_profile_recharge_product_config_record( } } -pub fn build_runtime_profile_membership_benefit_record( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> RuntimeProfileMembershipBenefitRecord { - RuntimeProfileMembershipBenefitRecord { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - starter_value: snapshot.starter_value, - basic_value: snapshot.basic_value, - pro_value: snapshot.pro_value, - ultimate_value: snapshot.ultimate_value, - } -} - pub fn build_runtime_profile_membership_record( snapshot: RuntimeProfileMembershipSnapshot, ) -> RuntimeProfileMembershipRecord { RuntimeProfileMembershipRecord { user_id: snapshot.user_id, status: snapshot.status, - tier: snapshot.tier, + plan: snapshot.plan, started_at: snapshot.started_at_micros.map(format_utc_micros), started_at_micros: snapshot.started_at_micros, expires_at: snapshot.expires_at_micros.map(format_utc_micros), @@ -1154,7 +1122,29 @@ pub fn build_runtime_profile_membership_record( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_period_days: snapshot.cycle_period_days, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + cycle_kind: snapshot.cycle_kind, + } +} + +pub fn build_runtime_profile_membership_upgrade_quote_record( + snapshot: RuntimeProfileMembershipUpgradeQuoteSnapshot, +) -> RuntimeProfileMembershipUpgradeQuoteRecord { + RuntimeProfileMembershipUpgradeQuoteRecord { + current_plan: snapshot.current_plan, + target_plan: snapshot.target_plan, + cycle_kind: snapshot.cycle_kind, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + amount_cents: snapshot.amount_cents, + granted_points_delta: snapshot.granted_points_delta, + monthly_balance_after: snapshot.monthly_balance_after, + expires_at: format_utc_micros(snapshot.expires_at_micros), + expires_at_micros: snapshot.expires_at_micros, + cycle_resets_at: format_utc_micros(snapshot.cycle_resets_at_micros), + cycle_resets_at_micros: snapshot.cycle_resets_at_micros, + remaining_full_months: snapshot.remaining_full_months, } } @@ -1375,25 +1365,6 @@ fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimePr Ok(()) } -fn days_in_month(year: i32, month: u8) -> u8 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if is_leap_year(year) => 29, - 2 => 28, - _ => 0, - } -} - -fn is_leap_year(year: i32) -> bool { - (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 -} - -fn weekday_from_date_key(date_key: i64) -> u8 { - // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 - (date_key + 3).rem_euclid(7) as u8 + 1 -} - fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 { let ordinal = ordinal_day(year, month, day); let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7); @@ -1430,36 +1401,6 @@ fn iso_weeks_in_year(year: i32) -> i32 { } } -fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { - // 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。 - let adjusted_year = year - if month <= 2 { 1 } else { 0 }; - let era = adjusted_year.div_euclid(400); - let year_of_era = adjusted_year - era * 400; - let month = i32::from(month); - let day = i32::from(day); - let month_prime = month + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * month_prime + 2) / 5 + day - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - i64::from(era * 146_097 + day_of_era - 719_468) -} - -fn civil_from_days(date_key: i64) -> (i32, u8, u8) { - // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 - let z = date_key + 719_468; - let era = z.div_euclid(146_097); - let day_of_era = z - era * 146_097; - let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - - day_of_era / 146_096) - .div_euclid(365); - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2).div_euclid(153); - let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += if month <= 2 { 1 } else { 0 }; - (year as i32, month as u8, day as u8) -} - pub fn build_default_runtime_profile_task_config( updated_at_micros: i64, updated_by: String, diff --git a/server-rs/crates/module-runtime/src/civil_calendar.rs b/server-rs/crates/module-runtime/src/civil_calendar.rs new file mode 100644 index 000000000..e4a679e5a --- /dev/null +++ b/server-rs/crates/module-runtime/src/civil_calendar.rs @@ -0,0 +1,116 @@ +//! 公历日期基元(不依赖运行环境时区)。 +//! +//! 只做「公历年月日 ↔ 1970-01-01 起日序号」的纯换算,采用 Howard Hinnant 的 civil calendar +//! 算法。北京时间(UTC+8)与业务日边界由调用方叠加固定偏移,因此本模块永远不读系统时区。 + +/// 公历给定年月的天数,含闰年规则。 +pub fn days_in_month(year: i32, month: u8) -> u8 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + 2 => 28, + _ => 0, + } +} + +/// 公历闰年判断。 +pub fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// 公历年月日 → 1970-01-01 起的日序号。 +/// +/// 全程用 `i64` 运算:`i32` 年份区间内 `era * 146_097` 会超过 `i32::MAX`,在 `i32` 下 +/// 会溢出(debug panic、release 静默回绕),因此这里的中间量一律提升到 `i64`。 +pub fn days_from_civil(year: i32, month: u8, day: u8) -> i64 { + // 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。 + let adjusted_year = i64::from(year) - if month <= 2 { 1 } else { 0 }; + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let month = i64::from(month); + let day = i64::from(day); + let month_prime = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * month_prime + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +/// 1970-01-01 起的日序号 → 公历年月日。 +/// +/// 只接受由 [`days_from_civil`] 产生、或落在公历可表示范围内的日序号(约 ±7.9e11 天,即 +/// `i32` 年份区间)。内部用 `saturating_add` 防止中间结果溢出:传入远小于/远大于该范围的 +/// 极端值时不会 panic,但会饱和并返回无意义的年月日,因此调用方需自行保证输入范围。 +pub fn civil_from_days(date_key: i64) -> (i32, u8, u8) { + // 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。 + let z = date_key.saturating_add(719_468); + let era = z.div_euclid(146_097); + let day_of_era = z - era * 146_097; + let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 + - day_of_era / 146_096) + .div_euclid(365); + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2).div_euclid(153); + let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + (year as i32, month as u8, day as u8) +} + +/// ISO 星期:周一 = 1,周日 = 7。 +pub fn weekday_from_date_key(date_key: i64) -> u8 { + // 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。 + (date_key.rem_euclid(7) + 3).rem_euclid(7) as u8 + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn civil_round_trip_holds_across_month_and_year_boundaries() { + for (year, month, day) in [ + (2024, 2, 29), + (2026, 1, 1), + (2026, 12, 31), + (1970, 1, 1), + (2100, 12, 31), + ] { + let date_key = days_from_civil(year, month, day); + assert_eq!(civil_from_days(date_key), (year, month, day)); + } + } + + #[test] + fn days_from_civil_handles_full_i32_year_range_without_overflow() { + // 中文注释:i32 年份区间内的 era 乘法会超过 i32::MAX,必须提升到 i64 才不溢出。 + for (year, month, day) in [ + (i32::MIN, 1, 1), + (i32::MIN, 2, 29), + (i32::MIN, 12, 31), + (i32::MAX, 1, 1), + (i32::MAX, 12, 31), + ] { + let date_key = days_from_civil(year, month, day); + assert_eq!(civil_from_days(date_key), (year, month, day)); + } + } + + #[test] + fn days_in_month_applies_leap_year_rules() { + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2100, 2), 28); + assert_eq!(days_in_month(2000, 2), 29); + assert_eq!(days_in_month(2026, 4), 30); + assert_eq!(days_in_month(2026, 12), 31); + } + + #[test] + fn weekday_from_date_key_matches_known_dates() { + // 中文注释:1970-01-01 是周四;2026-01-01 也是周四。 + assert_eq!(weekday_from_date_key(days_from_civil(1970, 1, 1)), 4); + assert_eq!(weekday_from_date_key(days_from_civil(2026, 1, 1)), 4); + } +} diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 138bb2cf3..458665486 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -99,8 +99,8 @@ pub fn build_runtime_profile_wallet_config_admin_upsert_input( if initial_mud_points == 0 || initial_mud_points > i64::MAX as u64 { return Err(RuntimeProfileFieldError::InvalidInitialWalletPoints); } - if daily_free_points_per_day == 0 || daily_free_points_per_day > i64::MAX as u64 { - return Err(RuntimeProfileFieldError::InvalidDailyFreePointsPerDay); + if daily_free_points_per_day > i64::MAX as u64 { + return Err(RuntimeProfileFieldError::DailyFreePointsPerDayOverflow); } Ok(RuntimeProfileWalletConfigAdminUpsertInput { admin_user_id, @@ -345,6 +345,13 @@ pub fn build_runtime_profile_recharge_center_get_input( Ok(RuntimeProfileRechargeCenterGetInput { user_id }) } +pub fn build_runtime_profile_membership_get_input( + user_id: String, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileMembershipGetInput { user_id }) +} + pub fn build_runtime_profile_recharge_order_get_input( order_id: String, ) -> Result { @@ -357,7 +364,6 @@ pub fn build_runtime_profile_recharge_order_create_input( user_id: String, product_id: String, payment_channel: String, - created_at_micros: i64, ) -> Result { let user_id = normalize_runtime_profile_user_id(user_id)?; let product_id = @@ -369,7 +375,6 @@ pub fn build_runtime_profile_recharge_order_create_input( user_id, product_id, payment_channel, - created_at_micros, }) } @@ -388,15 +393,8 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( price_cents: u64, kind: RuntimeProfileRechargeProductKind, points_amount: u64, - bonus_points: u64, - duration_days: u32, badge_label: String, description: String, - tier: RuntimeProfileMembershipTier, - membership_period_points: u64, - membership_period_days: u32, - membership_queue_limit: u32, - membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -414,28 +412,10 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( if points_amount == 0 { return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints); } - if duration_days != 0 - || tier != RuntimeProfileMembershipTier::Normal - || membership_period_points != 0 - || membership_period_days != 0 - || membership_queue_limit != 0 - || membership_discount_bps != 0 - { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); - } } + // 中文注释:会员商品已迁移到 profile_membership_plan 目录,这里只保留泥点商品。 RuntimeProfileRechargeProductKind::Membership => { - if duration_days == 0 { - return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration); - } - if points_amount != 0 - || bonus_points != 0 - || tier == RuntimeProfileMembershipTier::Normal - || membership_period_points == 0 - || membership_period_days == 0 - { - return Err(RuntimeProfileFieldError::InvalidRechargeProductTier); - } + return Err(RuntimeProfileFieldError::InvalidRechargeProductKind); } } @@ -446,21 +426,93 @@ pub fn build_runtime_profile_recharge_product_admin_upsert_input( 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, - membership_period_points, - membership_period_days, - membership_queue_limit, - membership_discount_bps, enabled, sort_order, updated_at_micros, }) } +pub fn build_runtime_profile_membership_plan_admin_list_input( + admin_user_id: String, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + Ok(RuntimeProfileMembershipPlanAdminListInput { admin_user_id }) +} + +/// 后台会员档位改价 / 改权益入参校验。 +/// +/// `normal` 是不可购买的非会员占位档位:价格与每期泥点一律按 0 落库; +/// 并发上限与模型权限固定为目录种子里的非会员基线,后台不能改。 +#[allow(clippy::too_many_arguments)] +pub fn build_runtime_profile_membership_plan_admin_upsert_input( + admin_user_id: String, + plan: RuntimeProfileMembershipPlan, + title: String, + month_price_cents: u64, + year_price_cents: u64, + period_points: u64, + model_access: RuntimeProfileMembershipModelAccess, + concurrent_job_limit: u32, + enabled: bool, + updated_at_micros: i64, +) -> Result { + let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; + let title = + normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?; + + if !plan.is_purchasable() { + // 中文注释:非会员基线固定取自目录种子,避免后台把「非会员」改成 0 并发或「不限并发」。 + let baseline = crate::resolve_runtime_profile_membership_plan_row(plan) + .ok_or(RuntimeProfileFieldError::InvalidMembershipPlan)?; + return Ok(RuntimeProfileMembershipPlanAdminUpsertInput { + admin_user_id, + plan, + title, + month_price_cents: 0, + year_price_cents: 0, + period_points: 0, + model_access: baseline.model_access, + concurrent_job_limit: baseline.concurrent_job_limit, + enabled: false, + updated_at_micros, + }); + } + + if month_price_cents == 0 + || year_price_cents == 0 + || period_points == 0 + || concurrent_job_limit == 0 + { + return Err(RuntimeProfileFieldError::InvalidMembershipPlan); + } + + Ok(RuntimeProfileMembershipPlanAdminUpsertInput { + admin_user_id, + plan, + title, + month_price_cents, + year_price_cents, + period_points, + model_access, + concurrent_job_limit, + enabled, + updated_at_micros, + }) +} + +pub fn build_runtime_profile_membership_upgrade_quote_input( + user_id: String, + target_plan: RuntimeProfileMembershipPlan, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + Ok(RuntimeProfileMembershipUpgradeQuoteGetInput { + user_id, + target_plan, + }) +} + 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 1edfb2d83..d8f6c7118 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -4,6 +4,8 @@ //! 纯领域事实与值对象;SpacetimeDB client 与 HTTP response 不进入本文件。 use serde::{Deserialize, Serialize}; + +use crate::RuntimeProfileMembershipPlanRecord; #[cfg(any())] use serde_json::Value; @@ -1152,6 +1154,7 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyFreeReset, RechargeRefundRecovery, LlmRouterConsume, + MembershipUpgradeGrant, /// 游戏买断制购买扣费(每账号每游戏一次)。 GamePurchase, } @@ -1175,6 +1178,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::DailyFreeReset => "daily_free_reset", Self::RechargeRefundRecovery => "recharge_refund_recovery", Self::LlmRouterConsume => "llm_router_consume", + Self::MembershipUpgradeGrant => "membership_upgrade_grant", Self::GamePurchase => "game_purchase", } } @@ -1230,6 +1234,10 @@ impl RuntimeProfileMembershipStatus { } } +/// 已退役:旧会员档位身份。 +/// +/// 仅为兼容存量 `profile_membership.tier` 与 `profile_recharge_product_config.tier` +/// 列保留,运行时不读不写;档位身份统一由 [`RuntimeProfileMembershipPlan`] 承载。 #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileMembershipTier { @@ -1243,21 +1251,447 @@ pub enum RuntimeProfileMembershipTier { Ultimate, } -impl RuntimeProfileMembershipTier { +/// 会员档位身份。取代旧的 `RuntimeProfileMembershipTier`,作为唯一权威档位标识。 +/// +/// 档位高低由目录表的 `rank` 决定(见 +/// [`runtime_profile_membership_plan_rank`](crate::runtime_profile_membership_plan_rank)); +/// 本枚举不实现 `PartialOrd` / `Ord`,变体声明顺序不构成排序契约(当前恰与 `rank` 一致), +/// 升级比较一律以目录 `rank` 为唯一来源。对外 wire 只用 +/// [`RuntimeProfileMembershipPlan::as_str`] 产出的枚举 token。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipPlan { + Normal, + Starter, + Plus, + Pro, + Max, +} + +impl RuntimeProfileMembershipPlan { pub fn as_str(&self) -> &'static str { match self { Self::Normal => "normal", - Self::Month => "month", - Self::Season => "season", - Self::Year => "year", Self::Starter => "starter", - Self::Basic => "basic", + Self::Plus => "plus", Self::Pro => "pro", - Self::Ultimate => "ultimate", + Self::Max => "max", + } + } + + /// 严格解析对外 wire token;未知 token 返回 `None`,调用方必须显式拒绝,不接受自由字符串。 + pub fn parse(value: &str) -> Option { + match value { + "normal" => Some(Self::Normal), + "starter" => Some(Self::Starter), + "plus" => Some(Self::Plus), + "pro" => Some(Self::Pro), + "max" => Some(Self::Max), + _ => None, + } + } + + /// `Normal` 不是可售档位,其余档位可在充值中心购买。 + pub fn is_purchasable(&self) -> bool { + !matches!(self, Self::Normal) + } +} + +/// 会员购买周期类型。它只决定本次购买的总期数与到期日,单期窗口一律按自然月推进。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipCycleKind { + Monthly, + Yearly, +} + +impl RuntimeProfileMembershipCycleKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Monthly => "monthly", + Self::Yearly => "yearly", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "monthly" => Some(Self::Monthly), + "yearly" => Some(Self::Yearly), + _ => None, + } + } + + /// 本次购买的总期数:月付 1 期、年付 12 期(每期仍是一个自然月)。 + pub fn cycle_count(&self) -> u32 { + match self { + Self::Monthly => 1, + Self::Yearly => 12, } } } +/// 档位允许选择的模型范围。本期只落字段、只用于展示,服务端不做拦截。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipModelAccess { + Basic, + Full, +} + +impl RuntimeProfileMembershipModelAccess { + pub fn as_str(&self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "basic" => Some(Self::Basic), + "full" => Some(Self::Full), + _ => None, + } + } +} + +/// 会员订单产生的权益变更类型,用于订单快照区分「新购」与「补差升级」。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipChangeKind { + Purchase, + Upgrade, +} + +impl RuntimeProfileMembershipChangeKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Purchase => "purchase", + Self::Upgrade => "upgrade", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "purchase" => Some(Self::Purchase), + "upgrade" => Some(Self::Upgrade), + _ => None, + } + } +} + +impl From for shared_contracts::runtime::ProfileMembershipPlanToken { + fn from(value: RuntimeProfileMembershipPlan) -> Self { + match value { + RuntimeProfileMembershipPlan::Normal => Self::Normal, + RuntimeProfileMembershipPlan::Starter => Self::Starter, + RuntimeProfileMembershipPlan::Plus => Self::Plus, + RuntimeProfileMembershipPlan::Pro => Self::Pro, + RuntimeProfileMembershipPlan::Max => Self::Max, + } + } +} + +impl From for RuntimeProfileMembershipPlan { + fn from(value: shared_contracts::runtime::ProfileMembershipPlanToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipPlanToken::Normal => Self::Normal, + shared_contracts::runtime::ProfileMembershipPlanToken::Starter => Self::Starter, + shared_contracts::runtime::ProfileMembershipPlanToken::Plus => Self::Plus, + shared_contracts::runtime::ProfileMembershipPlanToken::Pro => Self::Pro, + shared_contracts::runtime::ProfileMembershipPlanToken::Max => Self::Max, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipCycleKindToken +{ + fn from(value: RuntimeProfileMembershipCycleKind) -> Self { + match value { + RuntimeProfileMembershipCycleKind::Monthly => Self::Monthly, + RuntimeProfileMembershipCycleKind::Yearly => Self::Yearly, + } + } +} + +impl From + for RuntimeProfileMembershipCycleKind +{ + fn from(value: shared_contracts::runtime::ProfileMembershipCycleKindToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipCycleKindToken::Monthly => Self::Monthly, + shared_contracts::runtime::ProfileMembershipCycleKindToken::Yearly => Self::Yearly, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipStatusToken +{ + fn from(value: RuntimeProfileMembershipStatus) -> Self { + match value { + RuntimeProfileMembershipStatus::Normal => Self::Normal, + RuntimeProfileMembershipStatus::Active => Self::Active, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipModelAccessToken +{ + fn from(value: RuntimeProfileMembershipModelAccess) -> Self { + match value { + RuntimeProfileMembershipModelAccess::Basic => Self::Basic, + RuntimeProfileMembershipModelAccess::Full => Self::Full, + } + } +} + +impl From + for RuntimeProfileMembershipModelAccess +{ + fn from(value: shared_contracts::runtime::ProfileMembershipModelAccessToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipModelAccessToken::Basic => Self::Basic, + shared_contracts::runtime::ProfileMembershipModelAccessToken::Full => Self::Full, + } + } +} + +impl From + for shared_contracts::runtime::ProfileMembershipChangeKindToken +{ + fn from(value: RuntimeProfileMembershipChangeKind) -> Self { + match value { + RuntimeProfileMembershipChangeKind::Purchase => Self::Purchase, + RuntimeProfileMembershipChangeKind::Upgrade => Self::Upgrade, + } + } +} + +impl From + for RuntimeProfileMembershipChangeKind +{ + fn from(value: shared_contracts::runtime::ProfileMembershipChangeKindToken) -> Self { + match value { + shared_contracts::runtime::ProfileMembershipChangeKindToken::Purchase => Self::Purchase, + shared_contracts::runtime::ProfileMembershipChangeKindToken::Upgrade => Self::Upgrade, + } + } +} + +/// 会员业务拒绝的机器可读错误码。 +/// +/// 会员链路的人读 `error_message` 会随文案调整,调用方不得再按中文字符串判断 HTTP 状态; +/// 每个拒绝点显式产出本枚举,随 procedure 结果透传到 Axum 侧。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileMembershipErrorCode { + /// 补差升级报价 / 下单被升级规则拒绝。 + UpgradeRejected, + /// 当前不是会员,应直接购买套餐。 + NotMember, + /// 当前套餐已生效,无需重复购买。 + AlreadyActive, + /// 有效期内不支持月付 / 年付转换。 + CycleKindLocked, + /// 会员已到期,需按新购价格重新下单。 + MembershipExpired, + /// 下单后会员状态已变化,需要重新下单。 + StateChanged, + /// 目标档位当前不可购买。 + PlanNotPurchasable, + /// 代码内档位目录缺少该档位。 + PlanCatalogMissing, + /// 会员商品 product_id 无法解析。 + ProductIdUnparseable, + /// 会员商品已迁移到档位目录,旧商品不再可用。 + MembershipProductRetired, + /// 后台会员档位改价 / 改权益配置校验失败,或读取目录时发现价格 / 每期泥点倒挂。 + InvalidPlanConfig, +} + +impl RuntimeProfileMembershipErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::UpgradeRejected => "upgrade_rejected", + Self::NotMember => "not_member", + Self::AlreadyActive => "already_active", + Self::CycleKindLocked => "cycle_kind_locked", + Self::MembershipExpired => "membership_expired", + Self::StateChanged => "state_changed", + Self::PlanNotPurchasable => "plan_not_purchasable", + Self::PlanCatalogMissing => "plan_catalog_missing", + Self::ProductIdUnparseable => "product_id_unparseable", + Self::MembershipProductRetired => "membership_product_retired", + Self::InvalidPlanConfig => "invalid_plan_config", + } + } +} + +/// 兑换码核销业务拒绝的机器可读错误码。 +/// +/// 与 [`RuntimeProfileMembershipErrorCode`] 同样的目的:核销失败的人读 `error_message` +/// 会随文案调整,调用方不得按中文字符串判断 HTTP 状态,每个拒绝点显式产出本枚举并随 +/// procedure 结果透传到 Axum 侧。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileRewardCodeRedeemErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码已被后台停用。 + Disabled, + /// 兑换码尚未生效。 + NotStarted, + /// 兑换码已过期。 + Expired, + /// 兑换次数已用完。 + UsesExhausted, + /// 私有兑换码不适用于当前账号。 + NotAllowedForUser, + /// 兑换码奖励配置无效。 + InvalidReward, +} + +impl RuntimeProfileRewardCodeRedeemErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::Disabled => "disabled", + Self::NotStarted => "not_started", + Self::Expired => "expired", + Self::UsesExhausted => "uses_exhausted", + Self::NotAllowedForUser => "not_allowed_for_user", + Self::InvalidReward => "invalid_reward", + } + } +} + +/// 后台兑换码管理的机器可读拒绝码。 +/// +/// 与 [`RuntimeProfileRewardCodeRedeemErrorCode`] 同样的目的:后台兑换码的增改 / 停用拒绝 +/// 不能靠中文文案分类 HTTP 状态,每个拒绝点显式产出本枚举并随 procedure 结果透传。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileRedeemCodeAdminErrorCode { + /// 兑换码在目录里不存在。 + NotFound, + /// 兑换码管理入参不合法(缺码、奖励为 0、次数为 0、有效期倒挂等)。 + InvalidField, + /// 私有兑换码没有可用于兑换的用户。 + AllowedUsersRequired, +} + +impl RuntimeProfileRedeemCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::InvalidField => "invalid_field", + Self::AllowedUsersRequired => "allowed_users_required", + } + } +} + +/// 后台邀请码管理的机器可读拒绝码。 +/// +/// 与后台兑换码管理同一约定:邀请码增改的拒绝点显式产出本枚举并随 procedure 结果透传, +/// `api-server` 据此返回 400 而不是把业务拒绝当成 502。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeProfileInviteCodeAdminErrorCode { + /// 邀请码管理入参不合法(缺码、元数据不是对象、有效期倒挂等)。 + InvalidField, + /// 邀请码已被其他用户占用,不能改绑给当前用户。 + InUse, +} + +impl RuntimeProfileInviteCodeAdminErrorCode { + pub fn as_str(&self) -> &'static str { + match self { + Self::InvalidField => "invalid_field", + Self::InUse => "in_use", + } + } +} + +/// profile procedure 业务拒绝的统一错误码:把会员、兑换码核销、后台兑换码管理与后台邀请码管理 +/// 的 typed code 收在一处。 +/// +/// 只在 `spacetime-client` 进程内传递,不落表、不过 wire,因此不派生 `SpacetimeType`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeProfileProcedureRejectionCode { + Membership(RuntimeProfileMembershipErrorCode), + RewardCodeRedeem(RuntimeProfileRewardCodeRedeemErrorCode), + RedeemCodeAdmin(RuntimeProfileRedeemCodeAdminErrorCode), + InviteCodeAdmin(RuntimeProfileInviteCodeAdminErrorCode), +} + +impl RuntimeProfileProcedureRejectionCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Membership(code) => code.as_str(), + Self::RewardCodeRedeem(code) => code.as_str(), + Self::RedeemCodeAdmin(code) => code.as_str(), + Self::InviteCodeAdmin(code) => code.as_str(), + } + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileMembershipErrorCode) -> Self { + Self::Membership(code) + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRewardCodeRedeemErrorCode) -> Self { + Self::RewardCodeRedeem(code) + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileRedeemCodeAdminErrorCode) -> Self { + Self::RedeemCodeAdmin(code) + } +} + +impl From for RuntimeProfileProcedureRejectionCode { + fn from(code: RuntimeProfileInviteCodeAdminErrorCode) -> Self { + Self::InviteCodeAdmin(code) + } +} + +/// 会员领域错误:机器可读 `code` + 人读 `message`。 +/// +/// 只在模块内部与 procedure 结果装载之间传递,不落 SpacetimeDB 表,因此不派生 `SpacetimeType`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeProfileMembershipDomainError { + pub code: RuntimeProfileMembershipErrorCode, + pub message: String, +} + +impl RuntimeProfileMembershipDomainError { + pub fn new(code: RuntimeProfileMembershipErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl std::fmt::Display for RuntimeProfileMembershipDomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for RuntimeProfileMembershipDomainError {} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeProfileRechargeOrderStatus { @@ -1390,11 +1824,6 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1409,11 +1838,6 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -1422,26 +1846,12 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub updated_at_micros: i64, } -#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct RuntimeProfileMembershipBenefitSnapshot { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -1449,7 +1859,45 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, +} + +/// 只读会员快照的入参;后台详情等只读入口使用。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipGetInput { + pub user_id: String, +} + +/// 只读会员快照 procedure 的结果:成功时 `record` 为会员投影,失败时给出 `error_message`。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +/// 账号当前有效的 AGC 模型权限档。 +/// +/// 与写库刷新的充值中心快照不同,它只读会员账期投影与档位目录,不触发换期 / 免费点刷新,供 LLM 代理热路径使用。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +/// 只读 AGC 模型权限 procedure 的结果:成功时 `record` 为解析后的权限档,失败时给出 `error_message`。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1621,6 +2069,30 @@ pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { pub refunds: Vec, pub active_hold: Option, pub wallet: RuntimeProfileAdminWalletSnapshot, + /// 会员购买 / 升级的权益变更快照;人工复核退款时据此还原变更前后的档位、周期、补点与有效期。 + pub membership_change: Option, +} + +/// 会员订单的权益变更快照(只读投影,字段与 `profile_recharge_order` 的末位快照列一一对应)。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipOrderChangeSnapshot { + /// `purchase` 或 `upgrade`。 + pub change_kind: RuntimeProfileMembershipChangeKind, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: u64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + /// 订单价格拆分 JSON(目标档位价、当前档位价、实付金额),仅用于人工核对。 + pub price_breakdown_json: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1641,8 +2113,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, @@ -1662,6 +2133,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1679,15 +2151,8 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { 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 membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, @@ -1709,6 +2174,99 @@ pub struct RuntimeProfileRechargeProductAdminProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminListInput { + pub admin_user_id: String, +} + +/// 后台会员档位改价 / 改权益入参。 +/// +/// `rank` 不在入参里:升级比较基准由代码内的档位目录决定,后台不能改。 +/// `title` 仍可编辑,`normal` 档位的价格与额度会被忽略(它不可购买)。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminUpsertInput { + pub admin_user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanAdminListProcedureResult { + 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 RuntimeProfileMembershipPlanAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, + pub error_code: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +/// 补差升级报价快照(模块侧,时间戳为 micros)。报价与下单重算共用同一套规则。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { + pub current_plan: RuntimeProfileMembershipPlan, + pub target_plan: RuntimeProfileMembershipPlan, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +/// 补差升级报价的只读投影(记录侧,附 RFC3339 时间)。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteRecord { + pub current_plan: RuntimeProfileMembershipPlan, + pub target_plan: RuntimeProfileMembershipPlan, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at: String, + pub expires_at_micros: i64, + pub cycle_resets_at: String, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, + pub error_code: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterGetInput { @@ -1727,7 +2285,6 @@ pub struct RuntimeProfileRechargeOrderCreateInput { pub user_id: String, pub product_id: String, pub payment_channel: String, - pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2152,6 +2709,8 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 核销业务拒绝的机器可读错误码;非业务错误(入参、钱包等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2218,6 +2777,8 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 后台管理业务拒绝的机器可读错误码;非业务错误(入参、系统等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2227,6 +2788,8 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + /// 后台管理业务拒绝的机器可读错误码;非业务错误(入参、系统等)为 `None`。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2264,6 +2827,8 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2273,6 +2838,8 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + /// 业务拒绝的机器可读码;入参 / 系统错误为 `None`,由 `api-server` 落 502。 + pub error_code: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2504,11 +3071,6 @@ pub struct RuntimeProfileRechargeProductRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[derive(Clone, Debug, PartialEq)] @@ -2522,11 +3084,6 @@ pub struct RuntimeProfileRechargeProductConfigRecord { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -2550,24 +3107,11 @@ pub struct RuntimeProfileWalletConfigRecord { pub daily_free_points_per_day: u64, } -#[derive(Clone, Debug, PartialEq)] -pub struct RuntimeProfileMembershipBenefitRecord { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, -} - #[derive(Clone, Debug, PartialEq)] pub struct RuntimeProfileMembershipRecord { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at: Option, pub started_at_micros: Option, pub expires_at: Option, @@ -2580,7 +3124,9 @@ pub struct RuntimeProfileMembershipRecord { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } #[derive(Clone, Debug, PartialEq)] @@ -2615,8 +3161,7 @@ pub struct RuntimeProfileRechargeCenterRecord { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipRecord, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsRecord, diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index e8f1f0457..51b72df95 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -51,7 +51,7 @@ pub enum RuntimeProfileFieldError { InvalidWalletAmount, InvalidWalletMetadata, InvalidInitialWalletPoints, - InvalidDailyFreePointsPerDay, + DailyFreePointsPerDayOverflow, WalletAmountOverflow, WalletBalanceOverflow, InsufficientWalletBalance, @@ -88,9 +88,9 @@ pub enum RuntimeProfileFieldError { MissingProductTitle, InvalidRechargeProductPrice, InvalidRechargeProductPoints, - InvalidRechargeProductDuration, InvalidRechargeProductKind, - InvalidRechargeProductTier, + InvalidMembershipPlan, + InvalidMembershipPlanOrder, MissingPaymentChannel, #[cfg(any())] MissingWorldKey, @@ -136,8 +136,8 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidInitialWalletPoints => { f.write_str("profile_wallet_config.initial_mud_points 必须大于 0") } - Self::InvalidDailyFreePointsPerDay => { - f.write_str("profile_wallet_config.daily_free_points_per_day 必须大于 0") + Self::DailyFreePointsPerDayOverflow => { + f.write_str("profile_wallet_config.daily_free_points_per_day 超出上限") } Self::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"), Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"), @@ -181,11 +181,15 @@ impl std::fmt::Display for RuntimeProfileFieldError { 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::InvalidRechargeProductKind => f.write_str("充值商品类型无效"), - Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"), + Self::InvalidMembershipPlan => { + f.write_str("会员档位配置无效:月价、年价、每期泥点与并发上限必须有效") + } + Self::InvalidMembershipPlanOrder => f.write_str( + "会员档位必须按 rank 严格递增:更高档位的月价、年价与每期泥点都必须更高", + ), Self::MissingPaymentChannel => f.write_str("recharge.payment_channel 不能为空"), #[cfg(any())] Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index fd5f7252c..cedd299e1 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,26 +1,45 @@ pub mod agc_analytics; +pub mod civil_calendar; +pub mod membership; + +mod agc_model_access; mod agc_models; -mod llm_billing; -pub use llm_billing::*; mod payment; pub use payment::*; mod application; -pub use agc_models::*; mod commands; mod domain; mod errors; mod events; +mod llm_billing; +pub use agc_model_access::*; +pub use agc_models::*; pub use application::*; pub use commands::*; pub use domain::*; pub use errors::*; +pub use llm_billing::*; + +// 中文注释:`membership` 与 `civil_calendar` 不再用 `pub use ...::*` 摊平到 crate 根: +// 通配会把公历基元和目录内部件都变成公开 API,模块继续增长后容易命名冲突; +// 这里只重导出真正对外的会员契约符号,其余留在 `membership::` / `civil_calendar::` 命名空间下。 +pub use membership::{ + MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, + RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, + RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, missing_runtime_profile_membership_plan_rows, + parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, + resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, + runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, + validate_runtime_profile_membership_plan_row_against_catalog, +}; use shared_kernel::format_rfc3339 as format_shared_rfc3339; use time::OffsetDateTime; -pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30; - pub fn format_utc_micros(micros: i64) -> String { let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) .unwrap_or(OffsetDateTime::UNIX_EPOCH); @@ -29,166 +48,12 @@ pub fn format_utc_micros(micros: i64) -> String { pub fn runtime_profile_recharge_point_products() -> Vec { vec![ - build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"), - build_points_recharge_product( - "points_180", - "180泥点", - 1800, - 180, - 90, - "首充加赠", - "首充加赠90泥点", - ), - build_points_recharge_product( - "points_300", - "300泥点", - 3000, - 300, - 150, - "首充加赠", - "首充加赠150泥点", - ), - build_points_recharge_product( - "points_680", - "680泥点", - 6800, - 680, - 340, - "首充加赠", - "首充加赠340泥点", - ), - ] -} - -/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。 -pub fn resolve_runtime_profile_recharge_point_products( - _has_points_recharged: bool, -) -> Vec { - runtime_profile_recharge_point_products() -} - -pub fn runtime_profile_recharge_membership_products() -> Vec -{ - vec![ - build_membership_recharge_product( - "member_starter", - "Starter", - 1990, - 30, - RuntimeProfileMembershipTier::Starter, - "每月200泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_basic", - "Basic", - 6990, - 30, - RuntimeProfileMembershipTier::Basic, - "每月800泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_pro", - "Pro", - 19990, - 30, - RuntimeProfileMembershipTier::Pro, - "每月2500泥点 用于游戏创作", - ), - build_membership_recharge_product( - "member_ultimate", - "Ultimate", - 41990, - 30, - RuntimeProfileMembershipTier::Ultimate, - "每月6000泥点 用于游戏创作", - ), - ] -} - -pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option { - match tier { - RuntimeProfileMembershipTier::Normal => None, - RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1), - RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2), - RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3), - RuntimeProfileMembershipTier::Ultimate => Some(4), - } -} - -pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 { - match tier { - RuntimeProfileMembershipTier::Starter => 200, - RuntimeProfileMembershipTier::Basic => 800, - RuntimeProfileMembershipTier::Pro => 2500, - RuntimeProfileMembershipTier::Ultimate => 6000, - _ => 0, - } -} - -pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2, - RuntimeProfileMembershipTier::Pro => 5, - RuntimeProfileMembershipTier::Ultimate => 10, - _ => 0, - } -} - -pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 { - match tier { - RuntimeProfileMembershipTier::Basic => 8500, - RuntimeProfileMembershipTier::Pro => 8000, - RuntimeProfileMembershipTier::Ultimate => 7000, - _ => 0, - } -} - -pub fn runtime_profile_membership_benefits() -> Vec { - vec![ - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "特权名称".to_string(), - normal_value: "普通".to_string(), - month_value: "月卡".to_string(), - season_value: "季卡".to_string(), - year_value: "年卡".to_string(), - starter_value: "Starter".to_string(), - basic_value: "Basic".to_string(), - pro_value: "Pro".to_string(), - ultimate_value: "Ultimate".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "每月泥点".to_string(), - normal_value: "0".to_string(), - month_value: "0".to_string(), - season_value: "0".to_string(), - year_value: "0".to_string(), - starter_value: "200".to_string(), - basic_value: "800".to_string(), - pro_value: "2500".to_string(), - ultimate_value: "6000".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "同时排队".to_string(), - normal_value: "1".to_string(), - month_value: "2".to_string(), - season_value: "2".to_string(), - year_value: "2".to_string(), - starter_value: "2".to_string(), - basic_value: "2".to_string(), - pro_value: "5".to_string(), - ultimate_value: "10".to_string(), - }, - RuntimeProfileMembershipBenefitSnapshot { - benefit_name: "商业所有权".to_string(), - normal_value: "0%".to_string(), - month_value: "有".to_string(), - season_value: "有".to_string(), - year_value: "有".to_string(), - starter_value: "有".to_string(), - basic_value: "有".to_string(), - pro_value: "有".to_string(), - ultimate_value: "有".to_string(), - }, + build_points_recharge_product("points_60", "60泥点", 600, 60), + build_points_recharge_product("points_180", "180泥点", 1800, 180), + build_points_recharge_product("points_300", "300泥点", 3000, 300), + build_points_recharge_product("points_680", "680泥点", 6800, 680), + build_points_recharge_product("points_1280", "1280泥点", 12800, 1280), + build_points_recharge_product("points_3280", "3280泥点", 32800, 3280), ] } @@ -197,7 +62,6 @@ pub fn runtime_profile_recharge_product_by_id( ) -> Option { runtime_profile_recharge_point_products() .into_iter() - .chain(runtime_profile_recharge_membership_products()) .find(|product| product.product_id == product_id) } @@ -213,9 +77,6 @@ fn build_points_recharge_product( title: &str, price_cents: u64, points_amount: u64, - bonus_points: u64, - badge_label: &str, - description: &str, ) -> RuntimeProfileRechargeProductSnapshot { RuntimeProfileRechargeProductSnapshot { product_id: product_id.to_string(), @@ -223,41 +84,10 @@ fn build_points_recharge_product( price_cents, kind: RuntimeProfileRechargeProductKind::Points, points_amount, - bonus_points, - duration_days: 0, - badge_label: badge_label.to_string(), - description: description.to_string(), - tier: RuntimeProfileMembershipTier::Normal, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, - } -} - -fn build_membership_recharge_product( - product_id: &str, - title: &str, - price_cents: u64, - duration_days: u32, - tier: RuntimeProfileMembershipTier, - description: &str, -) -> RuntimeProfileRechargeProductSnapshot { - RuntimeProfileRechargeProductSnapshot { - product_id: product_id.to_string(), - title: title.to_string(), - price_cents, - kind: RuntimeProfileRechargeProductKind::Membership, - points_amount: 0, bonus_points: 0, - duration_days, + duration_days: 0, badge_label: String::new(), - description: description.to_string(), - tier, - membership_period_points: runtime_profile_membership_period_points(tier), - membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, - membership_queue_limit: runtime_profile_membership_queue_limit(tier), - membership_discount_bps: runtime_profile_membership_discount_bps(tier), + description: title.to_string(), } } @@ -863,7 +693,7 @@ mod tests { } #[test] - fn profile_wallet_config_requires_positive_daily_free_points() { + fn profile_wallet_config_allows_zero_daily_free_points() { assert_eq!( build_runtime_profile_wallet_config_admin_upsert_input( "admin-1".to_string(), @@ -871,19 +701,19 @@ mod tests { 1_713_680_000_000_000, 0, ) - .expect_err("zero daily free points should fail"), - RuntimeProfileFieldError::InvalidDailyFreePointsPerDay, + .expect("zero daily free points should pass") + .daily_free_points_per_day, + 0, ); assert_eq!( build_runtime_profile_wallet_config_admin_upsert_input( "admin-1".to_string(), 100, 1_713_680_000_000_000, - 35, + i64::MAX as u64 + 1, ) - .expect("positive daily free points should pass") - .daily_free_points_per_day, - 35, + .expect_err("overflowing daily free points should fail"), + RuntimeProfileFieldError::DailyFreePointsPerDayOverflow, ); } @@ -937,6 +767,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::LlmRouterConsume.as_str(), "llm_router_consume" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant.as_str(), + "membership_upgrade_grant" + ); } #[test] @@ -1123,59 +957,46 @@ mod tests { #[test] fn recharge_product_catalog_matches_reference_prices() { let point_products = runtime_profile_recharge_point_products(); - let membership_products = runtime_profile_recharge_membership_products(); - assert_eq!(point_products.len(), 4); - assert_eq!(point_products[0].product_id, "points_60"); - assert_eq!(point_products[0].title, "60泥点"); - assert_eq!(point_products[0].price_cents, 600); - assert_eq!(point_products[0].bonus_points, 0); - assert_eq!(point_products[0].badge_label, ""); - assert_eq!(point_products[0].description, "60泥点"); - assert_eq!(point_products[1].product_id, "points_180"); - assert_eq!(point_products[1].bonus_points, 90); - assert_eq!(point_products[2].bonus_points, 150); - assert_eq!(point_products[3].product_id, "points_680"); - assert_eq!(point_products[3].price_cents, 6800); - assert_eq!(point_products[3].bonus_points, 340); - assert_eq!(point_products[3].description, "首充加赠340泥点"); - assert_eq!(membership_products.len(), 4); - assert_eq!(membership_products[0].product_id, "member_starter"); - assert_eq!(membership_products[0].title, "Starter"); - assert_eq!(membership_products[0].price_cents, 1990); - assert_eq!(membership_products[0].membership_period_points, 200); + assert_eq!(point_products.len(), 6); + for (product, product_id, price_cents, points_amount) in [ + (&point_products[0], "points_60", 600, 60), + (&point_products[1], "points_180", 1800, 180), + (&point_products[2], "points_300", 3000, 300), + (&point_products[3], "points_680", 6800, 680), + (&point_products[4], "points_1280", 12800, 1280), + (&point_products[5], "points_3280", 32800, 3280), + ] { + assert_eq!(product.product_id, product_id); + assert_eq!(product.price_cents, price_cents); + assert_eq!(product.points_amount, points_amount); + // 中文注释:新方案取消首充赠送,六档充点一律无赠送、无角标。 + assert_eq!(product.bonus_points, 0); + assert_eq!(product.badge_label, ""); + } + + let plans = runtime_profile_membership_plan_catalog(); + assert_eq!(plans.len(), 5); + assert_eq!(plans[0].plan, RuntimeProfileMembershipPlan::Normal); + assert!(!plans[0].enabled); + assert_eq!(plans[0].period_points, 0); + assert_eq!(plans[0].concurrent_job_limit, 1); + assert_eq!(plans[1].plan, RuntimeProfileMembershipPlan::Starter); + assert_eq!(plans[1].month_price_cents, 3_900); + assert_eq!(plans[1].year_price_cents, 39_000); + assert_eq!(plans[1].period_points, 400); + assert_eq!(plans[2].plan, RuntimeProfileMembershipPlan::Plus); + assert_eq!(plans[2].period_points, 1_150); + assert_eq!(plans[3].plan, RuntimeProfileMembershipPlan::Pro); + assert_eq!(plans[3].period_points, 3_650); + assert_eq!(plans[4].plan, RuntimeProfileMembershipPlan::Max); + assert_eq!(plans[4].month_price_cents, 69_900); + assert_eq!(plans[4].year_price_cents, 699_000); + assert_eq!(plans[4].period_points, 8_650); assert_eq!( - membership_products[0].membership_period_days, - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS + plans[4].concurrent_job_limit, + MEMBERSHIP_UNLIMITED_CONCURRENCY ); - assert_eq!(membership_products[3].product_id, "member_ultimate"); - assert_eq!(membership_products[3].membership_period_points, 6000); - assert_eq!(membership_products[3].membership_queue_limit, 10); - - let benefits = runtime_profile_membership_benefits(); - assert!( - benefits - .iter() - .any(|benefit| benefit.benefit_name == "每月泥点") - ); - } - - #[test] - fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() { - let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); - assert_eq!(first_recharge_products[0].bonus_points, 0); - assert_eq!(first_recharge_products[0].badge_label, ""); - assert_eq!(first_recharge_products[0].description, "60泥点"); - assert_eq!(first_recharge_products[1].bonus_points, 90); - assert_eq!(first_recharge_products[1].badge_label, "首充加赠"); - assert_eq!(first_recharge_products[1].description, "首充加赠90泥点"); - - let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); - assert_eq!(repeated_recharge_products[0].bonus_points, 0); - assert_eq!(repeated_recharge_products[1].bonus_points, 90); - assert_eq!(repeated_recharge_products[3].bonus_points, 340); - assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠"); - assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点"); } #[test] @@ -1184,7 +1005,6 @@ mod tests { "user-1".to_string(), "custom-points-600".to_string(), "mock".to_string(), - 1, ) .expect("product existence is validated against database config later"); @@ -1198,7 +1018,6 @@ mod tests { "user-1".to_string(), "points_60".to_string(), " ".to_string(), - 1, ) .expect_err("missing payment channel should fail"); diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs new file mode 100644 index 000000000..828f14b55 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -0,0 +1,628 @@ +//! 会员计划目录(纯数据 + 解析)。 +//! +//! 目录一行一档:标题、`rank`、月价、独立可配置年价、每期泥点、模型权限、并发上限与 +//! 上下架。月付与年付共享同一 `period_points`(年付 = 12 次同额度发放),`cycle_count` +//! 由周期类型推导,不落目录字段。 +//! +//! 本模块是后台 `profile_membership_plan` 表的种子真相源,也是升级比较 `rank` 的唯一来源; +//! 价格是**后端唯一真相**,前端展示价只用于渲染,下单时后端按同一份目录重算。 +//! +//! 本期模型权限只落字段、只用于展示,不做服务端拦截;并发上限只做判定,同样不拦截。 +//! +//! 目录不变量:按 `rank` 升序,月价、年价、每期泥点必须**严格递增**(`Normal` 为 0)。 +//! 升级补差按 `rank` 判定、按价差 / 点差计价,若高 `rank` 的行反而更便宜或更少点, +//! 报价会直接报错;后台改价时由 [`validate_runtime_profile_membership_plan_row_against_catalog`] +//! 在写入侧拦住,禁止把倒挂目录落库。 + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +use crate::{ + RuntimeProfileFieldError, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, +}; + +/// 运营推荐档位。推荐是后端单一真相源:目录响应按此给出 `recommended` 标志, +/// 前端只消费标志,不再各自硬编码 `'plus'`。 +pub const PROFILE_MEMBERSHIP_RECOMMENDED_PLAN: RuntimeProfileMembershipPlan = + RuntimeProfileMembershipPlan::Plus; + +/// 目录一行:一个档位的可配置价格与权益。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanRecord { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub rank: u8, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, +} + +/// 目录行按选定周期解析出的「可下单套餐」快照:`price_cents` 只保留所选周期的那一档价格。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipPlanSnapshot { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub rank: u8, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_count: u32, + pub price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + /// 是否可在充值中心下单;`Normal` 与后台下架档位为 `false`。 + pub enabled: bool, +} + +/// 会员目录种子(一行一档,按 `rank` 升序)。 +/// +/// 数值来自产品原型 `充值与会员.html`,`Max` 的并发上限用哨兵 +/// [`MEMBERSHIP_UNLIMITED_CONCURRENCY`](crate::MEMBERSHIP_UNLIMITED_CONCURRENCY) 表示「不设上限」。 +/// `Normal` 是非会员占位档位,不可购买,因此 `enabled = false`。 +pub fn runtime_profile_membership_plan_catalog() -> Vec { + // TODO(membership-catalog): 每次解析都重建目录并重新分配每行字符串(maybe too many cp), + // 但只有 5 行、开销可忽略,按作者意见暂不修复(wont fix,评审第 10 项)。 + use RuntimeProfileMembershipModelAccess::{Basic, Full}; + use RuntimeProfileMembershipPlan::{Max, Normal, Plus, Pro, Starter}; + + vec![ + RuntimeProfileMembershipPlanRecord { + plan: Normal, + title: "非会员".to_string(), + rank: 0, + month_price_cents: 0, + year_price_cents: 0, + period_points: 0, + model_access: Basic, + concurrent_job_limit: 1, + enabled: false, + }, + RuntimeProfileMembershipPlanRecord { + plan: Starter, + title: "Starter".to_string(), + rank: 1, + month_price_cents: 3_900, + year_price_cents: 39_000, + period_points: 400, + model_access: Basic, + concurrent_job_limit: 1, + enabled: true, + }, + RuntimeProfileMembershipPlanRecord { + plan: Plus, + title: "Plus".to_string(), + rank: 2, + month_price_cents: 9_900, + year_price_cents: 99_000, + period_points: 1_150, + model_access: Full, + concurrent_job_limit: 3, + enabled: true, + }, + RuntimeProfileMembershipPlanRecord { + plan: Pro, + title: "Pro".to_string(), + rank: 3, + month_price_cents: 29_900, + year_price_cents: 299_000, + period_points: 3_650, + model_access: Full, + concurrent_job_limit: 5, + enabled: true, + }, + RuntimeProfileMembershipPlanRecord { + plan: Max, + title: "Max".to_string(), + rank: 4, + month_price_cents: 69_900, + year_price_cents: 699_000, + period_points: 8_650, + model_access: Full, + concurrent_job_limit: crate::MEMBERSHIP_UNLIMITED_CONCURRENCY, + enabled: true, + }, + ] +} + +/// 取某个档位的目录行;枚举本身永远有目录行,返回 `Option` 只是为了把「缺失」显式化。 +pub fn resolve_runtime_profile_membership_plan_row( + plan: RuntimeProfileMembershipPlan, +) -> Option { + runtime_profile_membership_plan_catalog() + .into_iter() + .find(|row| row.plan == plan) +} + +/// 目录里缺失的档位行(`existing` 中不存在的目录行),按目录顺序返回。 +/// +/// 用于把「只在空表时整表播种」改成「缺哪行补哪行」的幂等回填:老库可能只有部分档位, +/// 只在空表播种会让缺行的库永远缺下去。已存在的行不返回,调用方只插入缺失行、不覆盖后台改价。 +pub fn missing_runtime_profile_membership_plan_rows( + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Vec { + runtime_profile_membership_plan_catalog() + .into_iter() + .filter(|required| !existing.iter().any(|row| row.plan == required.plan)) + .collect() +} + +/// 校验后台写入的档位不会破坏目录不变量。 +/// +/// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 +/// 先校验 `existing` 覆盖除 `candidate.plan` 外的全部目录档位,再逐行比较 `candidate` 与其余 +/// 每一行:这样任何单次改写都不能制造新的倒挂,也不会因为「漏传某一行」而静默放过倒挂。 +/// +/// **调用方前置动作**:缺行的历史库必须先经 `ensure_default_profile_membership_plan` 回填 +/// 缺失档位(该函数按档位逐行补齐,不再只在空表时整表播种),否则本函数会直接拒绝写入。 +/// 这是有意的:缺行库继续允许任意单行改写会让目录长期残缺,报价时才暴露错误。 +/// +/// `model_access` 与 `concurrent_job_limit` 本期只落字段、只用于展示与人工判断,服务端不做拦截, +/// 因此这里不校验它们随 rank 单调;若将来据其做准入判定,需要在此补充倒挂校验。 +pub fn validate_runtime_profile_membership_plan_row_against_catalog( + candidate: &RuntimeProfileMembershipPlanRecord, + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Result<(), RuntimeProfileFieldError> { + // 覆盖校验:缺一行就无法判断它与 candidate 的相对关系,必须显式拒绝而不是静默跳过。 + for required in runtime_profile_membership_plan_catalog() { + if required.plan == candidate.plan { + continue; + } + if !existing.iter().any(|row| row.plan == required.plan) { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + + for row in existing.iter().filter(|row| row.plan != candidate.plan) { + if row.rank == candidate.rank { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + let (lower, higher) = if row.rank < candidate.rank { + (row, candidate) + } else { + (candidate, row) + }; + // 中文注释:月价、年价、每期泥点都必须随 rank 严格递增,否则升级补差会算出 0 元 / 0 泥点。 + if higher.month_price_cents <= lower.month_price_cents + || higher.year_price_cents <= lower.year_price_cents + || higher.period_points <= lower.period_points + { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + Ok(()) +} + +/// 目录行 → 可下单套餐快照,价格与权益来自目录表(后台可改)。 +pub fn build_runtime_profile_membership_plan_snapshot( + row: &RuntimeProfileMembershipPlanRecord, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> RuntimeProfileMembershipPlanSnapshot { + let price_cents = match cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => row.month_price_cents, + RuntimeProfileMembershipCycleKind::Yearly => row.year_price_cents, + }; + RuntimeProfileMembershipPlanSnapshot { + plan: row.plan, + title: row.title.clone(), + rank: row.rank, + cycle_kind, + cycle_count: cycle_kind.cycle_count(), + price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + enabled: row.enabled && row.plan.is_purchasable(), + } +} + +/// 档位 + 周期 → 可下单套餐快照;金额按所选周期的价格字段取值。 +pub fn resolve_runtime_profile_membership_plan_snapshot( + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Option { + let row = resolve_runtime_profile_membership_plan_row(plan)?; + Some(build_runtime_profile_membership_plan_snapshot( + &row, cycle_kind, + )) +} + +/// 升级比较基准:目录里的 `rank`(`Normal = 0`,越大越高)。 +/// +/// 返回 `Option` 是为了把「目录缺行」显式化:不再用 `unwrap_or(0)` 把缺失伪装成 `Normal`, +/// 否则缺失会被 `check_..._allowed` 误判成「目标不高于当前」→ 误报 `NotUpgrade`。 +pub fn runtime_profile_membership_plan_rank(plan: RuntimeProfileMembershipPlan) -> Option { + resolve_runtime_profile_membership_plan_row(plan).map(|row| row.rank) +} + +/// 只有在售档位可以下单:`Normal` 与下架档位都不允许购买。 +pub fn is_runtime_profile_membership_plan_purchasable(plan: RuntimeProfileMembershipPlan) -> bool { + resolve_runtime_profile_membership_plan_row(plan) + .map(|row| row.enabled && row.plan.is_purchasable()) + .unwrap_or(false) +} + +/// 会员商品的 `product_id` 前缀。 +/// +/// 会员购买 / 升级复用充值订单链路,因此订单里的 `product_id` 必须能唯一还原 +/// 「档位 + 周期」;`plan` 与 `cycle_kind` 的 token 都不含 `-`,所以拼接无歧义。 +pub const MEMBERSHIP_PRODUCT_ID_PREFIX: &str = "membership"; + +/// 档位 + 周期 → 会员商品 `product_id`(例如 `membership-pro-yearly`)。 +pub fn runtime_profile_membership_product_id( + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> String { + format!( + "{}-{}-{}", + MEMBERSHIP_PRODUCT_ID_PREFIX, + plan.as_str(), + cycle_kind.as_str() + ) +} + +/// 会员商品 `product_id` → 「档位 + 周期」;未知 token 一律返回 `None`,不做宽松匹配。 +pub fn parse_runtime_profile_membership_product_id( + product_id: &str, +) -> Option<( + RuntimeProfileMembershipPlan, + RuntimeProfileMembershipCycleKind, +)> { + let rest = product_id.strip_prefix(MEMBERSHIP_PRODUCT_ID_PREFIX)?; + let rest = rest.strip_prefix('-')?; + let (plan_token, cycle_token) = rest.rsplit_once('-')?; + Some(( + RuntimeProfileMembershipPlan::parse(plan_token)?, + RuntimeProfileMembershipCycleKind::parse(cycle_token)?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RuntimeProfileMembershipCycleKind, is_unlimited_concurrency}; + + fn row(plan: RuntimeProfileMembershipPlan) -> RuntimeProfileMembershipPlanRecord { + resolve_runtime_profile_membership_plan_row(plan).expect("每个档位都应有目录行") + } + + #[test] + fn catalog_covers_every_plan_once_in_rank_order() { + let catalog = runtime_profile_membership_plan_catalog(); + assert_eq!(catalog.len(), 5); + let plans = catalog.iter().map(|row| row.plan).collect::>(); + assert_eq!( + plans, + vec![ + RuntimeProfileMembershipPlan::Normal, + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipPlan::Max, + ] + ); + + assert!( + catalog.windows(2).all(|pair| pair[0].rank < pair[1].rank), + "目录必须按 rank 升序,且 rank 严格递增" + ); + } + + #[test] + fn catalog_prices_and_points_match_the_product_prototype() { + let starter = row(RuntimeProfileMembershipPlan::Starter); + assert_eq!(starter.month_price_cents, 3_900); + assert_eq!(starter.year_price_cents, 39_000); + assert_eq!(starter.period_points, 400); + assert_eq!( + starter.model_access, + RuntimeProfileMembershipModelAccess::Basic + ); + assert_eq!(starter.concurrent_job_limit, 1); + + let plus = row(RuntimeProfileMembershipPlan::Plus); + assert_eq!( + ( + plus.month_price_cents, + plus.year_price_cents, + plus.period_points + ), + (9_900, 99_000, 1_150) + ); + assert_eq!(plus.model_access, RuntimeProfileMembershipModelAccess::Full); + assert_eq!(plus.concurrent_job_limit, 3); + + let pro = row(RuntimeProfileMembershipPlan::Pro); + assert_eq!( + ( + pro.month_price_cents, + pro.year_price_cents, + pro.period_points + ), + (29_900, 299_000, 3_650) + ); + assert_eq!(pro.concurrent_job_limit, 5); + + let max = row(RuntimeProfileMembershipPlan::Max); + assert_eq!( + ( + max.month_price_cents, + max.year_price_cents, + max.period_points + ), + (69_900, 699_000, 8_650) + ); + assert!(is_unlimited_concurrency(max.concurrent_job_limit)); + } + + #[test] + fn enabled_plans_increase_in_price_and_points_with_rank() { + // 中文注释:升级补差依赖「rank 越高、价格与每期泥点越大」这一不变量; + // 后台改价破坏它时,升级会静默算成 0,因此把种子目录的不变量固化成回归断言。 + let mut rows = runtime_profile_membership_plan_catalog(); + rows.sort_by_key(|row| row.rank); + for pair in rows.windows(2) { + let (lower, higher) = (&pair[0], &pair[1]); + assert!( + higher.month_price_cents > lower.month_price_cents, + "{} 的月价必须高于 {}", + higher.title, + lower.title + ); + assert!( + higher.year_price_cents > lower.year_price_cents, + "{} 的年价必须高于 {}", + higher.title, + lower.title + ); + assert!( + higher.period_points > lower.period_points, + "{} 的每期泥点必须高于 {}", + higher.title, + lower.title + ); + } + } + + #[test] + fn catalog_seed_passes_the_admin_upsert_invariant() { + let catalog = runtime_profile_membership_plan_catalog(); + for candidate in &catalog { + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(candidate, &catalog), + Ok(()) + ); + } + } + + #[test] + fn admin_upsert_rejects_a_catalog_missing_a_plan() { + // 中文注释:缺行的库(例如老版本档位更少)必须先回填再校验。漏传其它档位时, + // 即使 candidate 本身与现存行单调,也必须拒绝,避免残缺目录继续扩大。 + let candidate = row(RuntimeProfileMembershipPlan::Plus); + let existing = vec![candidate.clone()]; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &existing), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn missing_plan_rows_backfills_only_absent_plans() { + // 老库只有 Plus:应补出目录里其余全部档位,且不重复返回已存在的 Plus。 + let existing = vec![row(RuntimeProfileMembershipPlan::Plus)]; + let missing = missing_runtime_profile_membership_plan_rows(&existing); + let plans = missing.iter().map(|row| row.plan).collect::>(); + let expected = runtime_profile_membership_plan_catalog() + .into_iter() + .map(|row| row.plan) + .filter(|plan| *plan != RuntimeProfileMembershipPlan::Plus) + .collect::>(); + assert_eq!(plans, expected); + + // 目录已补齐时不返回任何行:读取入口幂等、不再写库。 + assert!( + missing_runtime_profile_membership_plan_rows(&runtime_profile_membership_plan_catalog()) + .is_empty() + ); + } + + #[test] + fn admin_upsert_allows_a_monotonic_price_change() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents += 1; + candidate.year_price_cents += 1; + candidate.period_points += 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Ok(()) + ); + } + + #[test] + fn admin_upsert_rejects_inverted_price_or_points() { + let catalog = runtime_profile_membership_plan_catalog(); + + // 中文注释:把 Plus 定得比 Starter 便宜,破坏「越高档越贵」。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.month_price_cents = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:每期泥点与下一档持平也算倒挂(必须严格递增)。 + let mut candidate = row(RuntimeProfileMembershipPlan::Pro); + candidate.period_points = row(RuntimeProfileMembershipPlan::Plus).period_points; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + + // 中文注释:把 Plus 抬到比 Pro 还贵,同样是倒挂。 + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.year_price_cents = row(RuntimeProfileMembershipPlan::Pro).year_price_cents + 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn admin_upsert_rejects_duplicate_rank() { + let catalog = runtime_profile_membership_plan_catalog(); + let mut candidate = row(RuntimeProfileMembershipPlan::Plus); + candidate.rank = 1; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &catalog), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn year_price_is_stored_independently_of_the_month_price() { + // 中文注释:年价是独立可配置字段,这里断言的是种子数值本身,而不是月价 ×10 的推导关系。 + for (plan, year_price_cents) in [ + (RuntimeProfileMembershipPlan::Starter, 39_000_u64), + (RuntimeProfileMembershipPlan::Plus, 99_000), + (RuntimeProfileMembershipPlan::Pro, 299_000), + (RuntimeProfileMembershipPlan::Max, 699_000), + ] { + assert_eq!(row(plan).year_price_cents, year_price_cents); + } + } + + #[test] + fn normal_plan_grants_nothing_and_is_not_purchasable() { + let normal = row(RuntimeProfileMembershipPlan::Normal); + assert_eq!(normal.rank, 0); + assert_eq!( + ( + normal.month_price_cents, + normal.year_price_cents, + normal.period_points + ), + (0, 0, 0) + ); + assert_eq!( + normal.model_access, + RuntimeProfileMembershipModelAccess::Basic + ); + assert_eq!(normal.concurrent_job_limit, 1); + assert!(!normal.enabled); + assert!(!is_runtime_profile_membership_plan_purchasable( + RuntimeProfileMembershipPlan::Normal + )); + } + + #[test] + fn plan_rank_follows_catalog_order() { + assert_eq!( + runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Normal), + Some(0) + ); + assert_eq!( + runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Plus), + Some(2) + ); + assert_eq!( + runtime_profile_membership_plan_rank(RuntimeProfileMembershipPlan::Max), + Some(4) + ); + } + + #[test] + fn snapshot_uses_the_price_of_the_selected_cycle() { + let monthly = resolve_runtime_profile_membership_plan_snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Monthly, + ) + .expect("Pro 应有套餐快照"); + assert_eq!(monthly.price_cents, 29_900); + assert_eq!(monthly.cycle_count, 1); + + let yearly = resolve_runtime_profile_membership_plan_snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ) + .expect("Pro 应有套餐快照"); + assert_eq!(yearly.price_cents, 299_000); + assert_eq!(yearly.cycle_count, 12); + assert_eq!(yearly.period_points, 3_650); + assert_eq!(yearly.rank, monthly.rank); + } + + #[test] + fn only_enabled_plans_are_purchasable() { + for plan in [ + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipPlan::Max, + ] { + assert!(is_runtime_profile_membership_plan_purchasable(plan)); + } + } + + #[test] + fn membership_product_id_round_trips_plan_and_cycle() { + for (plan, cycle_kind, product_id) in [ + ( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Monthly, + "membership-starter-monthly", + ), + ( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + "membership-plus-yearly", + ), + ( + RuntimeProfileMembershipPlan::Max, + RuntimeProfileMembershipCycleKind::Yearly, + "membership-max-yearly", + ), + ] { + assert_eq!( + runtime_profile_membership_product_id(plan, cycle_kind), + product_id + ); + assert_eq!( + parse_runtime_profile_membership_product_id(product_id), + Some((plan, cycle_kind)) + ); + } + } + + #[test] + fn membership_product_id_rejects_non_membership_tokens() { + // 中文注释:充点商品 `points_*` 与未知 token 都不能被当成会员商品。 + assert_eq!( + parse_runtime_profile_membership_product_id("points_680"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-basic-monthly"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-plus-weekly"), + None + ); + assert_eq!( + parse_runtime_profile_membership_product_id("membership-plus"), + None + ); + } +} diff --git a/server-rs/crates/module-runtime/src/membership/cycle.rs b/server-rs/crates/module-runtime/src/membership/cycle.rs new file mode 100644 index 000000000..2a8c39fb2 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/cycle.rs @@ -0,0 +1,256 @@ +//! 会员账期的自然月推进(纯函数,无 IO)。 +//! +//! 账期锚点 = 北京时间(UTC+8)的「开通日时刻」。第 n 期一律从**原始锚点**重算, +//! 而不是从上一期递推,所以 1/31 开通得到的序列是 1/31 → 2/28 → 3/31,不会漂移成 3/28。 +//! 单期长度固定为一个自然月,日期按目标月实际长度夹取(`min(开通日, 目标月天数)`)。 +//! 中国不使用夏令时,因此这里用固定 UTC+8 偏移而不是时区库。 + +use crate::civil_calendar::{civil_from_days, days_from_civil, days_in_month}; +use crate::{PROFILE_RUNTIME_DAY_MICROS, PROFILE_TASK_BEIJING_OFFSET_MICROS}; + +/// 北京时间(UTC+8)某个时刻的本地「年月日 + 当日已过微秒」。 +/// +/// 与 [`runtime_profile_beijing_day_key`](crate::runtime_profile_beijing_day_key) 使用同一套 +/// 固定偏移与业务日边界,保证「同一个业务日」在所有余额投影里口径一致。 +pub fn beijing_local_date_parts(micros: i64) -> (i32, u8, u8, i64) { + let local_micros = micros.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS); + let day_key = local_micros.div_euclid(PROFILE_RUNTIME_DAY_MICROS); + let time_of_day_micros = local_micros.rem_euclid(PROFILE_RUNTIME_DAY_MICROS); + let (year, month, day) = civil_from_days(day_key); + (year, month, day, time_of_day_micros) +} + +/// 北京时间本地年月日 + 当日已过微秒 → UTC 微秒时间戳;本地日期或当日时刻越界时返回 `None`。 +/// +/// 返回 `Option` 而不是用 `0` 兼作非法哨兵:北京 1970-01-01 08:00 本身就是合法的 `0`, +/// 调用方必须能区分「非法日期」与「合法 epoch」。 +pub fn beijing_local_micros(year: i32, month: u8, day: u8, time_of_day_micros: i64) -> Option { + // 中文注释:先按 i64 微秒可表示范围(约 ±292_278 年)收口年份,避免 `days_from_civil` + // 内部的 `era * 146_097` 在 i32 上溢出(debug panic / release 回绕成看似合法的值)。 + if !(-292_278..=292_278).contains(&year) + || !(1..=12).contains(&month) + || day == 0 + || day > days_in_month(year, month) + || !(0..PROFILE_RUNTIME_DAY_MICROS).contains(&time_of_day_micros) + { + return None; + } + Some( + days_from_civil(year, month, day) + .checked_mul(PROFILE_RUNTIME_DAY_MICROS)? + .checked_add(time_of_day_micros)? + .checked_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS)?, + ) +} + +/// 把北京时间锚点推进 `months` 个自然月,日期夹取到目标月最后一天,时刻保持不变。 +/// +/// `months = 0` 返回锚点本身,因此调用方可以安全地用「第 n 期 = 推进 n 个自然月」表达账期。 +/// +/// 目标年份超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`: +/// 该输入在物理上不可表示,饱和既保留「更晚 / 更早」的方向,又避免历法乘法与 +/// [`beijing_local_micros`] 的 `checked_*` 溢出让这个纯函数 panic。 +pub fn advance_beijing_months_clamped(anchor_micros: i64, months: u32) -> i64 { + // 中文注释:`beijing_local_date_parts` 对极端 micros 会饱和后再反解,年份可能已被 i32 截断; + // 这里先按 i64 算目标年份,只在安全区间内才落回 i32 历法换算。 + const MIN_SAFE_YEAR: i64 = -300_000; + const MAX_SAFE_YEAR: i64 = 300_000; + + let (year, month, day, time_of_day_micros) = beijing_local_date_parts(anchor_micros); + let total_months = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(months); + let target_year = total_months.div_euclid(12); + let target_month = (total_months.rem_euclid(12) + 1) as u8; + if target_year < MIN_SAFE_YEAR { + return i64::MIN; + } + if target_year > MAX_SAFE_YEAR { + return i64::MAX; + } + let target_year = target_year as i32; + let target_day = day.min(days_in_month(target_year, target_month)); + beijing_local_micros(target_year, target_month, target_day, time_of_day_micros).unwrap_or_else( + || { + if anchor_micros < 0 { + i64::MIN + } else { + i64::MAX + } + }, + ) +} + +/// 第 `index` 期(1-based)的账期窗口 `(cycle_started_at, cycle_resets_at)`。 +/// +/// `index = 1` 是开通当期;`cycle_resets_at` 即下一期开始的时刻。窗口长度与周期类型无关, +/// 年付只是把同样的月度窗口重复 12 次;期数总数由 +/// [`RuntimeProfileMembershipCycleKind::cycle_count`](crate::RuntimeProfileMembershipCycleKind::cycle_count) +/// 给出。 +/// +/// `index = 0` 会被 `max(1)` 收敛为第 1 期(开通当期),不报错:这是为兼容「期号水位尚未初始化」 +/// 的存量行。它不同于 `quote_runtime_profile_membership_upgrade` 对 `cycle_index == 0` 的显式拒绝 +/// (报价要求调用方给出明确期号),调用方若已能拿到真实期号,不应把 `0` 当合法期号传入。 +pub fn membership_cycle_window(anchor_micros: i64, index: u32) -> (i64, i64) { + let index = index.max(1); + let start = if index == 1 { + anchor_micros + } else { + advance_beijing_months_clamped(anchor_micros, index - 1) + }; + let resets_at = advance_beijing_months_clamped(anchor_micros, index); + (start, resets_at) +} + +/// 本次购买的到期时刻:从锚点推进「总期数」个自然月(月付 1 期、年付 12 期)。 +pub fn membership_expires_at( + anchor_micros: i64, + cycle_kind: crate::RuntimeProfileMembershipCycleKind, +) -> i64 { + advance_beijing_months_clamped(anchor_micros, cycle_kind.cycle_count()) +} + +/// 从当前时刻到本期刷新时刻的剩余微秒;已过期返回 0。 +pub fn membership_cycle_remaining_micros(cycle_resets_at_micros: i64, now_micros: i64) -> i64 { + cycle_resets_at_micros.saturating_sub(now_micros).max(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RuntimeProfileMembershipCycleKind; + + /// 北京时间某时刻 → UTC 微秒。 + fn beijing(year: i32, month: u8, day: u8, hour: i64, minute: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000 + minute * 60_000_000) + .expect("测试用的北京本地时间应当合法") + } + + #[test] + fn advance_saturates_instead_of_panicking_on_extreme_anchors() { + // 中文注释:极端锚点已无法精确表示目标月份,本用例只要求「不 panic 且方向不反转」。 + assert_eq!(advance_beijing_months_clamped(i64::MAX, 1), i64::MAX); + assert!(advance_beijing_months_clamped(i64::MIN, 1) < 0); + assert_eq!(advance_beijing_months_clamped(i64::MAX, u32::MAX), i64::MAX); + } + + #[test] + fn beijing_local_parts_round_trip() { + let micros = beijing(2026, 10, 2, 9, 20); + assert_eq!( + beijing_local_date_parts(micros), + (2026, 10, 2, 9 * 3_600_000_000 + 20 * 60_000_000) + ); + } + + #[test] + fn beijing_local_micros_distinguishes_epoch_from_invalid_input() { + // 中文注释:北京 1970-01-01 08:00 就是合法的 UTC 0,不能再被 0 哨兵吞掉。 + assert_eq!(beijing(1970, 1, 1, 8, 0), 0); + assert_eq!(beijing_local_micros(2026, 13, 1, 0), None); + assert_eq!(beijing_local_micros(2026, 2, 30, 0), None); + assert_eq!( + beijing_local_micros(2026, 1, 1, PROFILE_RUNTIME_DAY_MICROS), + None + ); + } + + #[test] + fn beijing_local_micros_rejects_overflowing_year() { + // 中文注释:超出 i64 微秒范围的年份必须返回 None,而不是饱和成看似合法的值。 + assert_eq!(beijing_local_micros(1_000_000, 1, 1, 0), None); + assert_eq!(beijing_local_micros(-1_000_000, 1, 1, 0), None); + // 这些年份会让 days_from_civil 内部的 i32 乘法溢出,必须在进入历法换算前收口。 + assert_eq!(beijing_local_micros(10_000_000, 1, 1, 0), None); + assert_eq!(beijing_local_micros(-10_000_000, 1, 1, 0), None); + } + + #[test] + fn advance_months_clamps_to_month_end() { + // 中文注释:1/31 开通 → 2/28(2026 非闰年)→ 3/31,每期都从原始锚点重算。 + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!(advance_beijing_months_clamped(anchor, 0), anchor); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2026, 3, 31, 9, 20) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 12), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn advance_months_handles_leap_year() { + let anchor = beijing(2024, 1, 31, 12, 0); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2024, 2, 29, 12, 0) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 2), + beijing(2024, 3, 31, 12, 0) + ); + } + + #[test] + fn advance_months_crosses_year_boundary() { + let anchor = beijing(2026, 12, 31, 23, 30); + assert_eq!( + advance_beijing_months_clamped(anchor, 1), + beijing(2027, 1, 31, 23, 30) + ); + assert_eq!( + advance_beijing_months_clamped(anchor, 3), + beijing(2027, 3, 31, 23, 30) + ); + } + + #[test] + fn cycle_window_is_contiguous_from_the_anchor() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_cycle_window(anchor, 1), + (anchor, beijing(2026, 2, 28, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 2), + (beijing(2026, 2, 28, 9, 20), beijing(2026, 3, 31, 9, 20)) + ); + assert_eq!( + membership_cycle_window(anchor, 3), + (beijing(2026, 3, 31, 9, 20), beijing(2026, 4, 30, 9, 20)) + ); + } + + #[test] + fn cycle_window_treats_zero_index_as_first_period() { + let anchor = beijing(2026, 5, 20, 8, 0); + assert_eq!( + membership_cycle_window(anchor, 0), + membership_cycle_window(anchor, 1) + ); + } + + #[test] + fn expires_at_uses_total_period_count() { + let anchor = beijing(2026, 1, 31, 9, 20); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Monthly), + beijing(2026, 2, 28, 9, 20) + ); + assert_eq!( + membership_expires_at(anchor, RuntimeProfileMembershipCycleKind::Yearly), + beijing(2027, 1, 31, 9, 20) + ); + } + + #[test] + fn remaining_micros_never_goes_negative() { + assert_eq!(membership_cycle_remaining_micros(2_000, 1_000), 1_000); + assert_eq!(membership_cycle_remaining_micros(1_000, 1_000), 0); + assert_eq!(membership_cycle_remaining_micros(1_000, 2_000), 0); + } +} diff --git a/server-rs/crates/module-runtime/src/membership/limits.rs b/server-rs/crates/module-runtime/src/membership/limits.rs new file mode 100644 index 000000000..9f22a6ddd --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/limits.rs @@ -0,0 +1,31 @@ +//! 会员并发上限的判定(纯函数)。 +//! +//! 并发上限用 `u32` 落列与对外暴露,`>= MEMBERSHIP_UNLIMITED_CONCURRENCY`(128)视为「不设上限」。 +//! 刻意不用 `Option`:SpacetimeDB 表列与 DTO 都少一层可空分支,哨兵值只在领域层解释一次。 + +/// 并发上限哨兵值:等于或超过它即视为「不设上限」(`Max` 档)。 +pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128; + +/// 该并发上限是否表示「不设上限」。 +pub fn is_unlimited_concurrency(limit: u32) -> bool { + limit >= MEMBERSHIP_UNLIMITED_CONCURRENCY +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_limits_are_not_unlimited() { + for limit in [1, 3, 5, 127] { + assert!(!is_unlimited_concurrency(limit)); + } + } + + #[test] + fn sentinel_and_above_are_unlimited() { + for limit in [MEMBERSHIP_UNLIMITED_CONCURRENCY, 129, u32::MAX] { + assert!(is_unlimited_concurrency(limit)); + } + } +} diff --git a/server-rs/crates/module-runtime/src/membership/mod.rs b/server-rs/crates/module-runtime/src/membership/mod.rs new file mode 100644 index 000000000..5edfeeb37 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/mod.rs @@ -0,0 +1,20 @@ +//! 会员计费领域层(纯函数,无 IO)。 +//! +//! 只放会员侧的规则计算:档位目录与解析、自然月账期推进、并发上限哨兵与补差升级报价。 +//! SpacetimeDB 写入与事务编排留在 `spacetime-module`,HTTP 契约留在 `api-server`。 +//! +//! 子模块按依赖顺序排列: +//! - [`catalog`]:档位枚举 → 可下单套餐(价格 / 每期泥点 / 模型权限 / 并发)。 +//! - [`cycle`]:北京时间自然月账期(1/31 → 2/28 → 3/31,每期从原始锚点重算)。 +//! - [`limits`]:并发上限哨兵,`>= 128` 视为「不设上限」。 +//! - [`upgrade`]:补差升级报价与允许操作矩阵。 + +mod catalog; +mod cycle; +mod limits; +mod upgrade; + +pub use catalog::*; +pub use cycle::*; +pub use limits::*; +pub use upgrade::*; diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs new file mode 100644 index 000000000..e2677ec35 --- /dev/null +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -0,0 +1,670 @@ +//! 会员补差升级报价(纯函数,无 IO)。 +//! +//! 规则来自实施计划 §3.3,并且**金额与取整只有这一处实现**:报价接口与下单重算必须调用同一个函数, +//! 逐字段一致,前端展示价不是真相源。 +//! +//! - 月付:`应付 = 新月价 − 当前月价`,`补点 = 新每期额度 − 当前每期额度`(不按时间折算)。 +//! - 年付:`应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余比例)`, +//! `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。 +//! - 取整:金额**向上**取到分,补点**向下**取整。 +//! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。 +//! +//! 「本次购买总期数」由周期类型推导(`cycle_kind.cycle_count()`:月付 1 / 年付 12),不作为入参; +//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算: +//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。 +//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。 +//! +//! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期, +//! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。 +//! +//! 报价冻结后会员可能在支付前到期,此时补差价不再覆盖整期新购价, +//! 由 [`check_expired_membership_upgrade_settlement`] 在结算侧判定。 + +use serde::{Deserialize, Serialize}; + +use super::catalog::RuntimeProfileMembershipPlanSnapshot; +use super::cycle::membership_cycle_remaining_micros; +use crate::RuntimeProfileMembershipCycleKind; + +/// 比例分母:百万分之一。 +const REMAINING_RATIO_PPM: u128 = 1_000_000; +/// 年付按 12 个自然月折算。 +const MONTHS_PER_YEAR: u128 = 12; + +/// 报价接口与下单重算的共用输入。 +/// +/// 两个套餐快照由调用方从 `profile_membership_plan` 目录表解析后传入, +/// 这样价格与权益的唯一真相源始终是那张可被后台改价的表,而不是编译进代码的种子。 +/// +/// 本次购买总期数**不是**入参:它由目标周期类型推导(`target.cycle_kind.cycle_count()`, +/// 月付 1 / 年付 12),避免调用方传入与周期类型不一致的期数而算出错误金额。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuoteInput { + pub current: RuntimeProfileMembershipPlanSnapshot, + pub target: RuntimeProfileMembershipPlanSnapshot, + /// 当前期序号(1-based)。 + pub cycle_index: u32, + pub cycle_started_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub expires_at_micros: i64, + pub cycle_remaining_points: u64, + pub now_micros: i64, +} + +/// 补差升级报价结果;有效期与刷新日与变更前一致。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileMembershipUpgradeQuote { + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub cycle_index: u32, + pub remaining_full_months: u32, + pub remaining_ratio_ppm: u32, +} + +/// 升级被拒绝的原因;对外应映射为 4xx 与可读文案,而不是静默算出金额。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipUpgradeRejection { + /// 当前不是会员:应走下单购买流程,而不是补差升级。 + NotAMember, + /// 月转年 / 年转月:本期不支持即时转换,到期后按完整价格重新选择。 + CycleKindChangeUnsupported, + /// 同档位(重复购买)或降档:不允许,到期后再重新选择。 + NotUpgrade, + /// 目标档位不可购买(非会员占位或已下架)。 + TargetPlanNotPurchasable, + /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 + InvalidQuoteInput, + /// 目录价格 / 每期泥点未随 rank 严格递增(相等或倒挂)。 + /// + /// 与 [`Self::InvalidQuoteInput`] 分开:这是后端目录配置错误,不是调用方入参错误, + /// 对外应映射为配置类错误码(`invalid_plan_config`),便于监控区分。 + CatalogPricingInverted, + /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 + MembershipExpiredBeforeSettlement, +} + +impl RuntimeProfileMembershipUpgradeRejection { + pub fn as_str(&self) -> &'static str { + match self { + Self::NotAMember => "not_a_member", + Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported", + Self::NotUpgrade => "not_upgrade", + Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", + Self::InvalidQuoteInput => "invalid_quote_input", + Self::CatalogPricingInverted => "catalog_pricing_inverted", + Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", + } + } + + /// 对外 procedure 错误码:目录价格 / 每期泥点倒挂属后端配置错误,与调用方入参错误分开, + /// 便于按机器码定位;其余拒绝仍统一为 `UpgradeRejected`。 + pub fn error_code(&self) -> crate::RuntimeProfileMembershipErrorCode { + match self { + Self::CatalogPricingInverted => { + crate::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + _ => crate::RuntimeProfileMembershipErrorCode::UpgradeRejected, + } + } +} + +/// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 +pub fn membership_cycle_remaining_ratio_ppm( + cycle_started_at_micros: i64, + cycle_resets_at_micros: i64, + now_micros: i64, +) -> u32 { + let total_micros = cycle_resets_at_micros.saturating_sub(cycle_started_at_micros); + if total_micros <= 0 { + return 0; + } + let remaining_micros = membership_cycle_remaining_micros(cycle_resets_at_micros, now_micros); + let remaining_micros = remaining_micros.min(total_micros); + (u128::from(remaining_micros as u64) * REMAINING_RATIO_PPM / u128::from(total_micros as u64)) + as u32 +} + +/// 允许操作矩阵(实施计划 §3.4):只有「已是会员 + 同周期 + 升更高档位 + 目标可购买」才允许升级。 +pub fn check_runtime_profile_membership_upgrade_allowed( + current: &RuntimeProfileMembershipPlanSnapshot, + target: &RuntimeProfileMembershipPlanSnapshot, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if !current.plan.is_purchasable() { + return Err(RuntimeProfileMembershipUpgradeRejection::NotAMember); + } + if current.cycle_kind != target.cycle_kind { + return Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported); + } + if target.rank <= current.rank { + return Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade); + } + if !target.enabled || !target.plan.is_purchasable() { + return Err(RuntimeProfileMembershipUpgradeRejection::TargetPlanNotPurchasable); + } + Ok(()) +} + +/// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立; +/// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。 +/// +/// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」 +/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。 +/// +/// 账期窗口要求严格递增(`cycle_started_at < cycle_resets_at`):零时长窗口没有有效分母, +/// 按非法入参拒绝,而不是让它退化成「剩余比例为 0」。 +/// +/// `now` 必须落在账期窗口内(`cycle_started_at <= now < cycle_resets_at`):本期剩余比例与 +/// 期号是同一期账期的两个口径,账期已刷新仍按旧 `cycle_index` 报价会多收整整一个完整月 +/// (年付还会把补点算成 0)。生产入口先用 `project_profile_membership_cycle_at` 把会员行 +/// 投射到当前账期,因此这里拒绝的只会是「拿过期账期直接报价」的错误调用方。 +/// +/// 到期时刻不能早于本期刷新时刻(`expires_at >= cycle_resets_at`):账期刷新的前提是会员仍在 +/// 有效期内,否则报价会回显一个早于下次刷新日的到期时间,得到自相矛盾的结果。 +fn validate_runtime_profile_membership_upgrade_quote_input( + input: &RuntimeProfileMembershipUpgradeQuoteInput, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + let cycle_count = input.target.cycle_kind.cycle_count(); + let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count; + let window_out_of_order = input.cycle_resets_at_micros <= input.cycle_started_at_micros; + let now_outside_window = input.now_micros < input.cycle_started_at_micros + || input.now_micros >= input.cycle_resets_at_micros; + let expiry_out_of_order = input.expires_at_micros < input.cycle_resets_at_micros; + if cycle_index_out_of_range || window_out_of_order || now_outside_window || expiry_out_of_order + { + return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + } + Ok(()) +} + +/// 计算补差升级报价;报价与下单重算必须共用本函数。 +pub fn quote_runtime_profile_membership_upgrade( + input: RuntimeProfileMembershipUpgradeQuoteInput, +) -> Result { + check_runtime_profile_membership_upgrade_allowed(&input.current, &input.target)?; + validate_runtime_profile_membership_upgrade_quote_input(&input)?; + + let current = input.current; + let target = input.target; + + // 年付补差按「本期还剩几个完整月」折算;期号来自只前进的账期水位, + // 因此这里直接相减得到的剩余整月不会因为重复刷新而漂移。 + let cycle_count = target.cycle_kind.cycle_count(); + let remaining_full_months = cycle_count.saturating_sub(input.cycle_index); + let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.now_micros, + ); + + // 目录价格 / 每期泥点必须严格随 rank 递增;相等或倒挂都直接拒绝, + // 否则 checked_sub 会把「相等」算成 0 元升级 / 0 补点,违背目录不变量。 + // 这是后端目录配置错误,用独立变体与调用方入参错误区分。 + if target.price_cents <= current.price_cents || target.period_points <= current.period_points { + return Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted); + } + let price_delta_cents = target.price_cents - current.price_cents; + let points_delta = target.period_points - current.period_points; + + let (amount_cents, granted_points_delta) = match target.cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta), + RuntimeProfileMembershipCycleKind::Yearly => { + let weighted_months_ppm = u128::from(remaining_full_months) * REMAINING_RATIO_PPM + + u128::from(remaining_ratio_ppm); + let amount_cents = ceil_div( + u128::from(price_delta_cents) * weighted_months_ppm, + MONTHS_PER_YEAR * REMAINING_RATIO_PPM, + ); + let granted_points_delta = (u128::from(points_delta) * u128::from(remaining_ratio_ppm) + / REMAINING_RATIO_PPM) as u64; + (amount_cents, granted_points_delta) + } + }; + + Ok(RuntimeProfileMembershipUpgradeQuote { + amount_cents, + granted_points_delta, + monthly_balance_after: input + .cycle_remaining_points + .saturating_add(granted_points_delta), + expires_at_micros: input.expires_at_micros, + cycle_resets_at_micros: input.cycle_resets_at_micros, + cycle_index: input.cycle_index, + remaining_full_months, + remaining_ratio_ppm, + }) +} + +/// 补差价订单在「会员已到期」时是否仍可按新购结算。 +/// +/// 补差价只覆盖升级当期剩余权益的差额,前提是升级时会员仍在有效期内;一旦拖到到期后才支付, +/// 同一笔冻结金额就不再覆盖整期新购价。只有订单金额达到目标周期当前目录价才继续按新购结算, +/// 否则拒绝,避免用零头价换一整个周期。 +pub fn check_expired_membership_upgrade_settlement( + order_amount_cents: u64, + target_price_cents: u64, +) -> Result<(), RuntimeProfileMembershipUpgradeRejection> { + if order_amount_cents < target_price_cents { + return Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement); + } + Ok(()) +} + +/// 向上取整的整数除法(金额取整到分)。 +/// +/// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; +/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,release 下除零 panic, +/// 而不是被静默算成 0 元。 +fn ceil_div(numerator: u128, denominator: u128) -> u64 { + debug_assert!(denominator != 0, "ceil_div 的分母不能为 0"); + ((numerator + denominator - 1) / denominator) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RuntimeProfileMembershipErrorCode; + use crate::RuntimeProfileMembershipPlan; + use crate::membership::catalog::resolve_runtime_profile_membership_plan_snapshot; + use crate::membership::cycle::beijing_local_micros; + + const DAY_MICROS: i64 = 86_400_000_000; + + fn beijing(year: i32, month: u8, day: u8, hour: i64) -> i64 { + beijing_local_micros(year, month, day, hour * 3_600_000_000) + .expect("测试用的北京本地时间应当合法") + } + + fn snapshot( + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + ) -> RuntimeProfileMembershipPlanSnapshot { + resolve_runtime_profile_membership_plan_snapshot(plan, cycle_kind) + .expect("种子目录里每个档位都应有套餐快照") + } + + fn base_input() -> RuntimeProfileMembershipUpgradeQuoteInput { + let cycle_started_at_micros = beijing(2026, 6, 20, 9); + let cycle_resets_at_micros = beijing(2026, 7, 20, 9); + RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), + cycle_index: 3, + cycle_started_at_micros, + cycle_resets_at_micros, + expires_at_micros: beijing(2027, 3, 20, 9), + cycle_remaining_points: 615, + now_micros: cycle_started_at_micros + + (cycle_resets_at_micros - cycle_started_at_micros) / 2, + } + } + + #[test] + fn monthly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 月 → Pro 月:补付 ¥200、补点 2500、余额 615 + 2500 = 3115。 + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Monthly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Monthly, + ), + cycle_index: 1, + cycle_remaining_points: 615, + ..base_input() + }) + .expect("月付升级应可报价"); + + assert_eq!(quote.amount_cents, 20_000); + assert_eq!(quote.granted_points_delta, 2_500); + assert_eq!(quote.monthly_balance_after, 3_115); + assert_eq!(quote.remaining_full_months, 0); + } + + #[test] + fn yearly_upgrade_matches_the_spec_example() { + // 中文注释:Plus 年 → Pro 年,年付 12 期、当前第 3 期 → 剩 9 个完整月 + 半个当前月, + // 补付 ¥1583.34、补点 1250。 + let quote = + quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价"); + + assert_eq!(quote.amount_cents, 158_334); + assert_eq!(quote.granted_points_delta, 1_250); + assert_eq!(quote.monthly_balance_after, 1_865); + assert_eq!(quote.remaining_full_months, 9); + assert_eq!(quote.remaining_ratio_ppm, 500_000); + } + + #[test] + fn upgrade_keeps_expiry_reset_and_cycle_index() { + let input = base_input(); + let quote = + quote_runtime_profile_membership_upgrade(input.clone()).expect("年付升级应可报价"); + assert_eq!(quote.expires_at_micros, input.expires_at_micros); + assert_eq!(quote.cycle_resets_at_micros, input.cycle_resets_at_micros); + assert_eq!(quote.cycle_index, input.cycle_index); + } + + #[test] + fn remaining_ratio_is_zero_at_or_after_reset() { + let input = base_input(); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + ), + 0 + ); + assert_eq!( + membership_cycle_remaining_ratio_ppm( + input.cycle_started_at_micros, + input.cycle_resets_at_micros, + input.cycle_resets_at_micros + DAY_MICROS + ), + 0 + ); + } + + #[test] + fn quote_rejects_now_at_or_after_cycle_reset() { + let input = base_input(); + // 中文注释:账期已刷新仍按旧 `cycle_index` 报价会多收一个完整月(年付还把补点算成 0), + // 因此窗口上界与下界一样按非法入参拒绝;调用方应先投射账期到当前时刻。 + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros, + ..input.clone() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: input.cycle_resets_at_micros + DAY_MICROS, + ..input + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + + #[test] + fn non_member_must_purchase_instead_of_upgrading() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Normal, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotAMember) + ); + } + + #[test] + fn same_plan_and_downgrade_are_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade) + ); + } + + #[test] + fn invalid_numeric_input_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: 0, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_index: RuntimeProfileMembershipCycleKind::Yearly.cycle_count() + 1, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_resets_at_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + now_micros: beijing(2026, 6, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + // 中文注释:到期时刻早于本期刷新时刻属于自相矛盾的入参,必须显式拒绝。 + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + expires_at_micros: beijing(2026, 7, 1, 9), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + ); + } + + #[test] + fn inverted_catalog_price_and_points_are_rejected() { + // 中文注释:后台把更高档位定得更便宜 / 给更少每期泥点时,报价必须报错, + // 而不是被 saturating_sub 静默算成 0 元升级。 + let current = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + let mut target = snapshot( + RuntimeProfileMembershipPlan::Max, + RuntimeProfileMembershipCycleKind::Yearly, + ); + + target.price_cents = current.price_cents - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + target.price_cents = current.price_cents + 1; + target.period_points = current.period_points - 1; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + // 中文注释:相等同样破坏「严格随 rank 递增」,必须拒绝;旧实现用 checked_sub + // 会把相等的价差 / 点差算成 0,从而产出 0 元升级 / 0 补点。 + target.price_cents = current.price_cents; + target.period_points = current.period_points; + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current, + target, + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + // 中文注释:目录倒挂对外映射为配置错误码,与调用方入参错误(UpgradeRejected)区分。 + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted.error_code(), + RuntimeProfileMembershipErrorCode::InvalidPlanConfig + ); + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput.error_code(), + RuntimeProfileMembershipErrorCode::UpgradeRejected + ); + } + + #[test] + fn cycle_kind_change_is_rejected() { + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Monthly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported) + ); + // 中文注释:年付转月付时 `cycle_index = 3` 超过月付的 1 期;领域校验必须先于数值校验, + // 否则会被误判成 InvalidQuoteInput,破坏「账期变更不支持」这一对外契约。 + assert_eq!( + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Monthly, + ), + ..base_input() + }), + Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported) + ); + } + + #[test] + fn continuing_upgrade_compares_against_the_current_plan() { + // 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1583.34), + // 而不是按 Starter 直接跳 Pro(¥2058.34)。 + let starter_to_plus = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipCycleKind::Yearly, + ), + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Plus 应可报价"); + let starter_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + current: snapshot( + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipCycleKind::Yearly, + ), + target: snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ), + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Starter → Pro 应可报价"); + let plus_to_pro = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_remaining_points: 0, + ..base_input() + }) + .expect("Plus → Pro 应可报价"); + + assert_eq!(starter_to_plus.amount_cents, 47_500); + assert_eq!(starter_to_pro.amount_cents, 205_834); + assert_eq!(plus_to_pro.amount_cents, 158_334); + assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents); + } + + #[test] + fn yearly_amount_rounds_up_to_cent_and_points_round_down() { + // 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整); + // 年付 12 期、当前第 3 期 → 剩 9 个完整月,金额 200000 × (9 + 0.333333) / 12 + // = 155555.55 分 → 向上到 155556;补点 2500 × 0.333333 = 833.33 → 向下取整为 833。 + let started = beijing(2026, 6, 20, 9); + let quote = + quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput { + cycle_started_at_micros: started, + cycle_resets_at_micros: started + 3 * DAY_MICROS, + now_micros: started + 2 * DAY_MICROS, + ..base_input() + }) + .expect("年付升级应可报价"); + + assert_eq!(quote.remaining_ratio_ppm, 333_333); + assert_eq!(quote.amount_cents, 155_556); + assert_eq!(quote.granted_points_delta, 833); + } + + #[test] + fn expired_settlement_needs_the_full_target_price() { + // 中文注释:年付 Pro 目录价 299_000 分;补差价 158_334 分覆盖不了整期新购价,必须拒绝, + // 否则用户可在到期前用补差价建单、到期后再支付,等于用零头价换一个完整周期。 + let pro_yearly = snapshot( + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipCycleKind::Yearly, + ); + assert_eq!(pro_yearly.price_cents, 299_000); + assert_eq!( + check_expired_membership_upgrade_settlement(158_334, pro_yearly.price_cents), + Err(RuntimeProfileMembershipUpgradeRejection::MembershipExpiredBeforeSettlement) + ); + // 中文注释:订单金额本身已覆盖整期新购价(改价后挡位降价的容错路径)时按新购结算。 + assert_eq!( + check_expired_membership_upgrade_settlement(pro_yearly.price_cents, 299_000), + Ok(()) + ); + assert_eq!( + check_expired_membership_upgrade_settlement( + pro_yearly.price_cents + 1, + pro_yearly.price_cents + ), + Ok(()) + ); + } +} diff --git a/server-rs/crates/module-runtime/tests/membership_plan_enum.rs b/server-rs/crates/module-runtime/tests/membership_plan_enum.rs new file mode 100644 index 000000000..fb3cdfda1 --- /dev/null +++ b/server-rs/crates/module-runtime/tests/membership_plan_enum.rs @@ -0,0 +1,81 @@ +use module_runtime::{ + RuntimeProfileMembershipChangeKind, RuntimeProfileMembershipCycleKind, + RuntimeProfileMembershipModelAccess, RuntimeProfileMembershipPlan, +}; + +#[test] +fn membership_plan_tokens_round_trip_strictly() { + // 中文注释:对外 wire token 必须与枚举一一对应,未知 token 一律解析失败。 + let cases = [ + (RuntimeProfileMembershipPlan::Normal, "normal"), + (RuntimeProfileMembershipPlan::Starter, "starter"), + (RuntimeProfileMembershipPlan::Plus, "plus"), + (RuntimeProfileMembershipPlan::Pro, "pro"), + (RuntimeProfileMembershipPlan::Max, "max"), + ]; + for (plan, token) in cases { + assert_eq!(plan.as_str(), token); + assert_eq!(RuntimeProfileMembershipPlan::parse(token), Some(plan)); + } + + assert_eq!(RuntimeProfileMembershipPlan::parse("basic"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse("ultimate"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse("PLUS"), None); + assert_eq!(RuntimeProfileMembershipPlan::parse(""), None); +} + +#[test] +fn membership_plan_normal_is_not_purchasable() { + // 中文注释:Normal 是非会员占位档位,不允许被购买。 + assert!(!RuntimeProfileMembershipPlan::Normal.is_purchasable()); + for plan in [ + RuntimeProfileMembershipPlan::Starter, + RuntimeProfileMembershipPlan::Plus, + RuntimeProfileMembershipPlan::Pro, + RuntimeProfileMembershipPlan::Max, + ] { + assert!(plan.is_purchasable()); + } +} + +#[test] +fn membership_cycle_kind_maps_to_period_count() { + // 中文注释:年付 12 期、月付 1 期,每期仍是一个自然月。 + assert_eq!(RuntimeProfileMembershipCycleKind::Monthly.cycle_count(), 1); + assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.cycle_count(), 12); + assert_eq!( + RuntimeProfileMembershipCycleKind::Monthly.as_str(), + "monthly" + ); + assert_eq!(RuntimeProfileMembershipCycleKind::Yearly.as_str(), "yearly"); + assert_eq!( + RuntimeProfileMembershipCycleKind::parse("yearly"), + Some(RuntimeProfileMembershipCycleKind::Yearly) + ); + assert_eq!(RuntimeProfileMembershipCycleKind::parse("weekly"), None); +} + +#[test] +fn membership_model_access_and_change_kind_tokens_round_trip() { + assert_eq!(RuntimeProfileMembershipModelAccess::Basic.as_str(), "basic"); + assert_eq!(RuntimeProfileMembershipModelAccess::Full.as_str(), "full"); + assert_eq!( + RuntimeProfileMembershipModelAccess::parse("full"), + Some(RuntimeProfileMembershipModelAccess::Full) + ); + assert_eq!(RuntimeProfileMembershipModelAccess::parse("premium"), None); + + assert_eq!( + RuntimeProfileMembershipChangeKind::Purchase.as_str(), + "purchase" + ); + assert_eq!( + RuntimeProfileMembershipChangeKind::Upgrade.as_str(), + "upgrade" + ); + assert_eq!( + RuntimeProfileMembershipChangeKind::parse("upgrade"), + Some(RuntimeProfileMembershipChangeKind::Upgrade) + ); + assert_eq!(RuntimeProfileMembershipChangeKind::parse("downgrade"), None); +} diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index ad9d7af64..4fb6dc499 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -3,6 +3,11 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::runtime::{ + ProfileMembershipChangeKindToken, ProfileMembershipCycleKindToken, + ProfileMembershipModelAccessToken, ProfileMembershipPlanToken, ProfileMembershipStatusToken, +}; + #[cfg(any())] use crate::creation_entry_config::{ CreationEntryEventBannerResponse, PublicWorkInteractionConfigResponse, @@ -10,7 +15,7 @@ use crate::creation_entry_config::{ }; /// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。 -pub const ADMIN_TAB_PERMISSIONS: [&str; 21] = [ +pub const ADMIN_TAB_PERMISSIONS: [&str; 22] = [ "dashboard", "overview", "tables", @@ -23,6 +28,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 21] = [ "profile-wallet", "tasks", "recharge-products", + "membership-plans", "recharge-orders", "editor-generation-pricing", "editor-showcase", @@ -1185,7 +1191,8 @@ pub struct AdminProfileWalletPayload { pub total_balance: u64, pub spendable_balance: u64, pub daily_free_points: u64, - pub membership_limited_points: u64, + /// 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 + pub monthly_points: u64, pub permanent_points: u64, pub held_points: u64, pub refund_debt_points: u64, @@ -1233,6 +1240,31 @@ pub struct AdminRechargeRefundHoldPayload { pub updated_at_micros: i64, } +/// 会员购买 / 升级的权益变更快照(只读)。 +/// +/// 与 `profile_recharge_order` 的末位快照列一一对应,供订单列表与退款人工复核还原变更 +/// 前后的档位、账期、补点与有效期。非会员订单为 `None`。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminMembershipOrderChangePayload { + /// `purchase` 或 `upgrade`。 + pub change_kind: ProfileMembershipChangeKindToken, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: u64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + /// 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);字符串原样透出供人工核对。 + pub price_breakdown_json: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminRechargeOrderEntryPayload { @@ -1262,6 +1294,8 @@ pub struct AdminRechargeOrderEntryPayload { pub remaining_refundable_cents: u64, pub refund_eligible: bool, pub refund_block_reason_code: Option, + /// 会员购买 / 升级快照;非会员订单为 `None`。 + pub membership_change: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1294,9 +1328,27 @@ pub struct AdminUserDetailResponse { pub cumulative_recharged_cents: Option, pub can_reconcile_consumption: bool, pub wallet: AdminProfileWalletPayload, + pub membership: AdminProfileMembershipPayload, pub recharge_orders: Vec, } +/// 单用户当前会员状态与账期(只读),供后台用户详情判断档位、周期与到期可见性。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProfileMembershipPayload { + pub plan: ProfileMembershipPlanToken, + pub status: ProfileMembershipStatusToken, + pub cycle_kind: ProfileMembershipCycleKindToken, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_granted_points: u64, + pub cycle_remaining_points: u64, + pub started_at_micros: Option, + pub expires_at_micros: Option, + pub cycle_resets_at_micros: Option, + pub updated_at_micros: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminUserConsumptionReconcileRequest { @@ -1653,6 +1705,11 @@ fn default_codex_agent_mode() -> String { "codex".to_string() } +/// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。 +fn default_basic_agc_model_access() -> ProfileMembershipModelAccessToken { + ProfileMembershipModelAccessToken::Basic +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AdminAgcModel { @@ -1667,6 +1724,9 @@ pub struct AdminAgcModel { /// 服务端回读时始终给出解析后的具体值。 #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, + /// 该模型要求的访问级别:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 + #[serde(default = "default_basic_agc_model_access")] + pub access: ProfileMembershipModelAccessToken, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 3ef622bf1..46436d316 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -24,5 +24,6 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod llm_catalog; pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index a02708cf7..a729f0994 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -60,22 +60,9 @@ mod tests { assert_eq!(payload["messages"][0]["role"], json!("system")); } } -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelSummary { - pub display_name: String, - pub id: String, - /// 执行器:`codex` 或 `cc`。 - pub agent_mode: String, - /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。 - pub protocol: String, -} - -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelsResponse { - pub default_model_id: String, - pub models: Vec, - /// 模型目录版本,客户端据此做条件刷新。 - pub revision: u64, -} +// 目录 DTO 与枚举已迁到 `llm_catalog`(同一 ts-rs 生成目录);这里 re-export 保持既有 +// `shared_contracts::llm::*` 路径可用。 +pub use crate::llm_catalog::{ + AgcAgentMode, AgcModelProtocol, LlmModelSummary, LlmModelUnavailableReason, LlmModelsResponse, + LlmUnavailableModel, +}; diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs new file mode 100644 index 000000000..3b67537d5 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -0,0 +1,371 @@ +//! AGC LLM 模型目录的对外契约与枚举(前后端唯一真源)。 +//! +//! `GET /api/llm/models` 返回两个平行数组:`models` 只放本档可用模型; +//! `unavailableModels` 放目录里其余全部(含后台停用与本档档位不够),元素带 `reason`。 +//! 整个 DTO 与枚举经 ts-rs 导出到 `packages/shared/src/contracts/generated/`, +//! AGC 前端不再手写第二份类型。 + +use serde::{Deserialize, Serialize}; + +/// 客户端启动的执行器。与线协议正交。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcAgentMode { + Codex, + /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; + /// 协议本身由 [`AgcModelProtocol`] 表达。 + #[serde(alias = "anthropic")] + Cc, +} + +impl Default for AgcAgentMode { + fn default() -> Self { + Self::Codex + } +} + +impl AgcAgentMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Cc => "cc", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "cc" => Ok(Self::Cc), + _ => Err("Agent 模式只允许 codex 或 cc".into()), + } + } + + /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: + /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 + /// Anthropic Messages。 + pub const fn supports(self, protocol: AgcModelProtocol) -> bool { + match self { + Self::Codex => matches!( + protocol, + AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat + ), + Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), + } + } + + /// 存量目录缺少协议字段时按执行器的默认线协议解释。 + pub const fn default_protocol(self) -> AgcModelProtocol { + match self { + Self::Codex => AgcModelProtocol::OpenAiResponses, + Self::Cc => AgcModelProtocol::Anthropic, + } + } +} + +/// 上游线协议,与执行器正交。 +/// +/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 +/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 +/// `/api/llm/anthropic/{*path}`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcModelProtocol { + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "anthropic")] + Anthropic, +} + +impl Default for AgcModelProtocol { + fn default() -> Self { + Self::OpenAiResponses + } +} + +impl AgcModelProtocol { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAiResponses => "openai_responses", + Self::OpenAiChat => "openai_chat", + Self::Anthropic => "anthropic", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "openai_responses" => Ok(Self::OpenAiResponses), + "openai_chat" => Ok(Self::OpenAiChat), + "anthropic" => Ok(Self::Anthropic), + _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), + } + } +} + +/// 响应 DTO 的前向兼容:未知执行器回退 `Codex`,避免后端新增取值时旧客户端整份 +/// [`LlmModelsResponse`] 反序列化失败。写入 / 输入校验仍走严格的 [`AgcAgentMode::parse`]。 +fn deserialize_forward_compat_agent_mode<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + Ok(match raw.as_deref() { + Some("cc") | Some("anthropic") => AgcAgentMode::Cc, + _ => AgcAgentMode::Codex, + }) +} + +/// 响应 DTO 的前向兼容:未知协议回退 `None`,由 `agent_mode` 的默认协议补齐。 +fn deserialize_forward_compat_protocol<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let raw = Option::::deserialize(deserializer)?; + Ok(match raw.as_deref() { + Some("openai_responses") => Some(AgcModelProtocol::OpenAiResponses), + Some("openai_chat") => Some(AgcModelProtocol::OpenAiChat), + Some("anthropic") => Some(AgcModelProtocol::Anthropic), + _ => None, + }) +} + +/// 反序列化中间态:`protocol` 缺失时按 `agent_mode` 的默认协议补齐(见 `From` 实现)。 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LlmModelSummaryWire { + display_name: String, + id: String, + #[serde(default, deserialize_with = "deserialize_forward_compat_agent_mode")] + agent_mode: AgcAgentMode, + #[serde(default, deserialize_with = "deserialize_forward_compat_protocol")] + protocol: Option, +} + +impl From for LlmModelSummary { + fn from(wire: LlmModelSummaryWire) -> Self { + let protocol = wire + .protocol + .unwrap_or_else(|| wire.agent_mode.default_protocol()); + Self { + display_name: wire.display_name, + id: wire.id, + agent_mode: wire.agent_mode, + protocol, + } + } +} + +/// 本档可用的一条模型摘要。不包含上游真实模型名。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", from = "LlmModelSummaryWire")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelSummary { + pub display_name: String, + pub id: String, + /// 执行器;旧服务端缺字段时按 `codex` 兼容。 + #[serde(default)] + pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 + #[serde(default)] + pub protocol: AgcModelProtocol, +} + +/// 反序列化中间态:与 [`LlmModelSummaryWire`] 同规则,额外携带 `reason`。 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LlmUnavailableModelWire { + display_name: String, + id: String, + #[serde(default, deserialize_with = "deserialize_forward_compat_agent_mode")] + agent_mode: AgcAgentMode, + #[serde(default, deserialize_with = "deserialize_forward_compat_protocol")] + protocol: Option, + reason: LlmModelUnavailableReason, +} + +impl From for LlmUnavailableModel { + fn from(wire: LlmUnavailableModelWire) -> Self { + let protocol = wire + .protocol + .unwrap_or_else(|| wire.agent_mode.default_protocol()); + Self { + display_name: wire.display_name, + id: wire.id, + agent_mode: wire.agent_mode, + protocol, + reason: wire.reason, + } + } +} + +/// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", from = "LlmUnavailableModelWire")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmUnavailableModel { + pub display_name: String, + pub id: String, + #[serde(default)] + pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。 + #[serde(default)] + pub protocol: AgcModelProtocol, + pub reason: LlmModelUnavailableReason, +} + +/// 模型对当前账号不可用的原因。 +/// +/// `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum LlmModelUnavailableReason { + /// 会员档位不够(升级可解锁)。 + PlanRequired, + /// 后台人工停用(升级也解锁不了)。 + Disabled, + /// 未知原因的前向兜底;当前服务端不会主动产出。 + #[serde(other)] + Unknown, +} + +/// `GET /api/llm/models` 的响应。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelsResponse { + pub default_model_id: String, + /// 本档可用,保持目录顺序。 + pub models: Vec, + /// 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + #[serde(default)] + pub unavailable_models: Vec, + /// 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub revision: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_protocol_follows_agent_mode() { + let cc: LlmModelSummary = + serde_json::from_str(r#"{"displayName":"Claude","id":"model-a","agentMode":"cc"}"#) + .unwrap(); + assert_eq!(cc.protocol, AgcModelProtocol::Anthropic); + + let legacy_alias: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"Claude","id":"model-a","agentMode":"anthropic"}"#, + ) + .unwrap(); + assert_eq!(legacy_alias.agent_mode, AgcAgentMode::Cc); + assert_eq!(legacy_alias.protocol, AgcModelProtocol::Anthropic); + + let codex: LlmModelSummary = + serde_json::from_str(r#"{"displayName":"GPT","id":"model-b"}"#).unwrap(); + assert_eq!(codex.agent_mode, AgcAgentMode::Codex); + assert_eq!(codex.protocol, AgcModelProtocol::OpenAiResponses); + + // 显式 protocol 优先,不被 agent_mode 覆盖。 + let explicit: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"GPT","id":"model-c","agentMode":"codex","protocol":"openai_chat"}"#, + ) + .unwrap(); + assert_eq!(explicit.protocol, AgcModelProtocol::OpenAiChat); + } + + #[test] + fn unavailable_model_missing_protocol_follows_agent_mode() { + let cc: LlmUnavailableModel = serde_json::from_str( + r#"{"displayName":"Claude","id":"model-a","agentMode":"cc","reason":"disabled"}"#, + ) + .unwrap(); + assert_eq!(cc.protocol, AgcModelProtocol::Anthropic); + assert_eq!(cc.reason, LlmModelUnavailableReason::Disabled); + } + + #[test] + fn unknown_enum_tokens_fall_back_for_forward_compat() { + // 后端将来新增执行器 / 协议时,旧客户端仍能解析整份响应,而不是 fail closed。 + let summary: LlmModelSummary = serde_json::from_str( + r#"{"displayName":"未来模型","id":"model-x","agentMode":"future-mode","protocol":"future-proto"}"#, + ) + .unwrap(); + assert_eq!(summary.agent_mode, AgcAgentMode::Codex); + assert_eq!(summary.protocol, AgcModelProtocol::OpenAiResponses); + + let unavailable: LlmUnavailableModel = serde_json::from_str( + r#"{"displayName":"未来模型","id":"model-x","agentMode":"future-mode","protocol":"future-proto","reason":"future-reason"}"#, + ) + .unwrap(); + assert_eq!(unavailable.agent_mode, AgcAgentMode::Codex); + assert_eq!(unavailable.protocol, AgcModelProtocol::OpenAiResponses); + assert_eq!(unavailable.reason, LlmModelUnavailableReason::Unknown); + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 1e1d7ad21..9b1c47830 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -27,6 +27,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_c pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = "puzzle_author_incentive_claim"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD: &str = "daily_task_reward"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_UPGRADE_GRANT: &str = + "membership_upgrade_grant"; pub const PROFILE_TASK_CYCLE_DAILY: &str = "daily"; pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete"; pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable"; @@ -34,14 +36,6 @@ 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_MEMBERSHIP_TIER_STARTER: &str = "starter"; -pub const PROFILE_MEMBERSHIP_TIER_BASIC: &str = "basic"; -pub const PROFILE_MEMBERSHIP_TIER_PRO: &str = "pro"; -pub const PROFILE_MEMBERSHIP_TIER_ULTIMATE: &str = "ultimate"; pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open"; pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; @@ -198,9 +192,23 @@ pub struct ProfileDashboardSummaryResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileDailyFreePointsResponse { + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub day_key: i64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub granted_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub remaining_points: u64, pub resets_at: String, pub updated_at: String, @@ -208,14 +216,32 @@ pub struct ProfileDailyFreePointsResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMudPointBalanceResponse { - pub total_points: u64, - pub permanent_points: u64, - pub limited_points: u64, - pub limited_expires_at: Option, + /// 每日免费泥点:北京业务日有效,跨日清零并重置。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub daily_free_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub daily_free_reset_points: u64, pub daily_free_resets_at: String, + /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + #[serde(default)] + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub monthly_points: u64, + pub monthly_resets_at: Option, + /// 永久泥点:不过期,创作照常扣点。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub permanent_points: u64, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -236,67 +262,380 @@ pub struct ProfileWalletLedgerResponse { pub entries: Vec, } +/// 会员档位 token;与前端 `ProfileMembershipPlan` 联合类型一一对应。 +/// +/// 用枚举而非 `String` 收口 wire 值:未知 token 在反序列化阶段即被拒绝,后台也不能再 +/// 写入任意档位字符串。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum ProfileMembershipPlanToken { + Normal, + Starter, + Plus, + Pro, + Max, +} + +impl ProfileMembershipPlanToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Starter => "starter", + Self::Plus => "plus", + Self::Pro => "pro", + Self::Max => "max", + } + } +} + +/// 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum ProfileMembershipCycleKindToken { + Monthly, + Yearly, +} + +impl ProfileMembershipCycleKindToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Monthly => "monthly", + Self::Yearly => "yearly", + } + } +} + +/// 会员状态 token;与前端 `ProfileMembershipStatus` 联合类型一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum ProfileMembershipStatusToken { + Normal, + Active, +} + +impl ProfileMembershipStatusToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Active => "active", + } + } +} + +/// 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum ProfileMembershipModelAccessToken { + Basic, + Full, +} + +impl ProfileMembershipModelAccessToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Full => "full", + } + } +} + +/// 会员订单权益变更类型 token;区分“新购”与“补差升级”。 +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum ProfileMembershipChangeKindToken { + Purchase, + Upgrade, +} + +impl ProfileMembershipChangeKindToken { + pub fn as_str(&self) -> &'static str { + match self { + Self::Purchase => "purchase", + Self::Upgrade => "upgrade", + } + } +} + +/// 响应侧前向兼容反序列化:后端新增 token 取值时,旧 Rust 客户端(AGC Tauri shell 的 +/// `account_api` 会把整份 `ProfileRechargeCenterResponse` / `...QuoteResponse` 反序列化) +/// 不应因为一个枚举取值而整包失败。 +/// +/// 只用于**响应** DTO 字段;入参 DTO(`ProfileMembershipUpgradeQuoteRequest`、 +/// `AdminUpsertProfileMembershipPlanRequest`)继续用严格 enum,非法 token 仍然拒绝。 +/// +/// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 +/// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, +/// 避免把付费会员误显示成非会员后引导重复购买。 +/// +/// 字段层面同样做前向兼容:本模块新增的响应字段一律带 `#[serde(default ...)]`,让「客户端先于 +/// 服务端发布」的滚动升级不会因为旧服务端缺字段而整包失败。身份 / 状态字段(`plan` / `status` +/// / `enabled`)保持必填:给它们兜底默认值会造成 fail-open 或 fail-closed 的静默误判。 +fn deserialize_forward_compat_membership_plan<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipPlanToken::Normal, + Some("starter") => ProfileMembershipPlanToken::Starter, + Some("plus") => ProfileMembershipPlanToken::Plus, + Some("pro") => ProfileMembershipPlanToken::Pro, + Some("max") => ProfileMembershipPlanToken::Max, + _ => ProfileMembershipPlanToken::Max, + }, + ) +} + +fn deserialize_forward_compat_membership_status<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("normal") => ProfileMembershipStatusToken::Normal, + _ => ProfileMembershipStatusToken::Active, + }, + ) +} + +fn deserialize_forward_compat_membership_cycle_kind<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("monthly") => ProfileMembershipCycleKindToken::Monthly, + _ => default_forward_compat_membership_cycle_kind(), + }, + ) +} + +/// `cycle_kind` 缺失时的兜底(旧服务端还没下发该字段):与未知 token 一样落到 `yearly`。 +fn default_forward_compat_membership_cycle_kind() -> ProfileMembershipCycleKindToken { + ProfileMembershipCycleKindToken::Yearly +} + +/// `cycle_index` 缺失时的兜底:1-based,最小合法期号是 1。 +fn default_membership_cycle_index() -> u32 { + 1 +} + +/// `cycle_count` 缺失时的兜底:月付 1 期,是与 1-based 期号自洽的最小值。 +fn default_membership_cycle_count() -> u32 { + 1 +} + +fn deserialize_forward_compat_model_access<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok( + match Option::::deserialize(deserializer)?.as_deref() { + Some("basic") => ProfileMembershipModelAccessToken::Basic, + _ => ProfileMembershipModelAccessToken::Full, + }, + ) +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeProductResponse { pub product_id: String, pub title: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub price_cents: u64, pub kind: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub points_amount: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub bonus_points: u64, pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: String, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct ProfileMembershipBenefitResponse { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct ProfileMembershipPlanResponse { + /// 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] + pub plan: ProfileMembershipPlanToken, + pub title: String, + pub rank: u8, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub month_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub year_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub period_points: u64, + /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_model_access")] + pub model_access: ProfileMembershipModelAccessToken, + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 + pub concurrent_job_limit: u32, + /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + #[serde(default)] + pub recommended: bool, + /// 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + #[serde(default)] + pub unlimited_concurrency: bool, + pub enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileMembershipResponse { - pub status: String, - pub tier: String, + /// 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] + pub status: ProfileMembershipStatusToken, + /// 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] + pub plan: ProfileMembershipPlanToken, pub started_at: Option, pub expires_at: Option, pub updated_at: Option, pub cycle_started_at: Option, pub cycle_resets_at: Option, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_granted_points: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + /// 当前期序号(1-based)。 + #[serde(default = "default_membership_cycle_index")] + pub cycle_index: u32, + /// 本次购买总期数(月付 1 / 年付 12)。 + #[serde(default = "default_membership_cycle_count")] + pub cycle_count: u32, + /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 + #[serde( + default = "default_forward_compat_membership_cycle_kind", + deserialize_with = "deserialize_forward_compat_membership_cycle_kind" + )] + pub cycle_kind: ProfileMembershipCycleKindToken, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeOrderResponse { pub order_id: String, pub product_id: String, pub product_title: String, pub kind: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub amount_cents: u64, pub status: String, pub payment_channel: String, pub paid_at: Option, pub provider_transaction_id: Option, pub created_at: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub points_delta: i64, pub membership_expires_at: Option, pub expired_at: Option, @@ -348,13 +687,24 @@ pub struct WechatNativePaymentResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] pub struct ProfileRechargeCenterResponse { - pub wallet_balance: u64, pub mud_point_balance: ProfileMudPointBalanceResponse, pub membership: ProfileMembershipResponse, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + /// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + #[serde(default)] + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: ProfileDailyFreePointsResponse, @@ -576,11 +926,6 @@ pub struct ProfileRechargeProductConfigAdminResponse { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: String, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, @@ -659,27 +1004,126 @@ pub struct AdminUpsertProfileRechargeProductRequest { 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)] - pub membership_period_points: u64, - #[serde(default)] - pub membership_period_days: u32, - #[serde(default)] - pub membership_queue_limit: u32, - #[serde(default)] - pub membership_discount_bps: u32, #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] pub sort_order: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct ProfileMembershipUpgradeQuoteRequest { + /// 目标档位 token:`starter` / `plus` / `pro` / `max`。 + pub target_plan: ProfileMembershipPlanToken, +} + +/// 补差升级报价:付款前展示应付金额、补点与变更后的月度余额;下单时后端会用同一函数重算。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct ProfileMembershipUpgradeQuoteResponse { + /// 当前档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] + pub current_plan: ProfileMembershipPlanToken, + /// 目标档位 token;未知取值按 `max` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_plan")] + pub target_plan: ProfileMembershipPlanToken, + /// 周期 token:`monthly` / `yearly`;有效期内不支持月年互换,所以变更前后一致; + /// 未知取值按 `yearly` 兜底。 + #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] + pub cycle_kind: ProfileMembershipCycleKindToken, + pub cycle_index: u32, + pub cycle_count: u32, + /// 应付金额(分);月付 = 月价差,年付 = 年价差 ÷ 12 × (后续完整月数 + 本期剩余比例),向上取整到分。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub amount_cents: u64, + /// 本期补点;年付按本期剩余比例向下取整。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub granted_points_delta: u64, + /// 变更后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久泥点不动)。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub monthly_balance_after: u64, + pub expires_at: Option, + pub cycle_resets_at: Option, + pub remaining_full_months: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct ProfileMembershipPlanAdminListResponse { + pub entries: Vec, + /// 并发哨兵阈值:`concurrent_job_limit >= 该值` 即不设上限;由后端下发,前端不硬编码。 + pub unlimited_concurrency_threshold: u32, +} + +/// 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct AdminUpsertProfileMembershipPlanRequest { + /// 档位 token,作为主键即档位身份。 + pub plan: ProfileMembershipPlanToken, + pub title: String, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub month_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub year_price_cents: u64, + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub period_points: u64, + /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 + pub model_access: ProfileMembershipModelAccessToken, + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 + pub concurrent_job_limit: u32, + #[serde(default = "default_true")] + pub enabled: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileWalletConfigRequest { @@ -1324,13 +1768,12 @@ mod tests { fn empty_mud_point_balance() -> ProfileMudPointBalanceResponse { ProfileMudPointBalanceResponse { - total_points: 0, - permanent_points: 0, - limited_points: 0, - limited_expires_at: None, daily_free_points: 0, daily_free_reset_points: 20, daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + monthly_points: 0, + monthly_resets_at: None, + permanent_points: 0, } } @@ -1607,27 +2050,27 @@ mod tests { #[test] fn profile_recharge_center_response_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileRechargeCenterResponse { - wallet_balance: 29, mud_point_balance: ProfileMudPointBalanceResponse { - total_points: 29, - permanent_points: 4, - limited_points: 5, - limited_expires_at: Some("2026-05-25T10:00:00Z".to_string()), daily_free_points: 20, daily_free_reset_points: 20, daily_free_resets_at: "2026-07-12T16:00:00Z".to_string(), + monthly_points: 5, + monthly_resets_at: Some("2026-05-25T10:00:00Z".to_string()), + permanent_points: 4, }, membership: ProfileMembershipResponse { - status: "active".to_string(), - tier: "month".to_string(), + status: ProfileMembershipStatusToken::Active, + plan: ProfileMembershipPlanToken::Plus, started_at: Some("2026-04-25T10:00:00Z".to_string()), expires_at: Some("2026-05-25T10:00:00Z".to_string()), updated_at: Some("2026-04-25T10:00:00Z".to_string()), cycle_started_at: Some("2026-04-25T10:00:00Z".to_string()), cycle_resets_at: Some("2026-05-25T10:00:00Z".to_string()), - cycle_granted_points: 800, + cycle_granted_points: 1_150, cycle_remaining_points: 320, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![ProfileRechargeProductResponse { product_id: "points_60".to_string(), @@ -1637,16 +2080,22 @@ mod tests { points_amount: 60, bonus_points: 60, duration_days: 0, - badge_label: "首充双倍".to_string(), - description: "首充送60泥点".to_string(), - tier: "normal".to_string(), - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, + badge_label: String::new(), + description: "60泥点".to_string(), + }], + membership_plans: vec![ProfileMembershipPlanResponse { + plan: ProfileMembershipPlanToken::Plus, + title: "Plus".to_string(), + rank: 2, + month_price_cents: 9_900, + year_price_cents: 99_000, + period_points: 1_150, + model_access: ProfileMembershipModelAccessToken::Full, + concurrent_job_limit: 3, + recommended: true, + unlimited_concurrency: false, + enabled: true, }], - membership_products: vec![], - benefits: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { @@ -1659,21 +2108,28 @@ mod tests { }) .expect("payload should serialize"); - assert_eq!(payload["walletBalance"], json!(29)); assert_eq!(payload["mudPointBalance"]["permanentPoints"], json!(4)); - assert_eq!(payload["mudPointBalance"]["limitedPoints"], json!(5)); + assert_eq!(payload["mudPointBalance"]["monthlyPoints"], json!(5)); assert_eq!(payload["mudPointBalance"]["dailyFreePoints"], json!(20)); + assert_eq!( + payload["mudPointBalance"]["monthlyResetsAt"], + json!("2026-05-25T10:00:00Z") + ); + assert_eq!(payload["membership"]["plan"], json!("plus")); assert_eq!( payload["membership"]["expiresAt"], json!("2026-05-25T10:00:00Z") ); assert_eq!(payload["membership"]["cycleRemainingPoints"], json!(320)); + assert_eq!(payload["membership"]["cycleKind"], json!("monthly")); assert_eq!(payload["pointProducts"][0]["productId"], json!("points_60")); assert_eq!(payload["pointProducts"][0]["title"], json!("60泥点")); assert_eq!(payload["pointProducts"][0]["priceCents"], json!(600)); + assert_eq!(payload["pointProducts"][0]["description"], json!("60泥点")); + assert_eq!(payload["membershipPlans"][0]["plan"], json!("plus")); assert_eq!( - payload["pointProducts"][0]["description"], - json!("首充送60泥点") + payload["membershipPlans"][0]["concurrentJobLimit"], + json!(3) ); assert_eq!(payload["hasPointsRecharged"], json!(false)); assert_eq!(payload["dailyFreePoints"]["grantedPoints"], json!(20)); @@ -1711,11 +2167,10 @@ mod tests { expiration_last_error: None, }; let center = ProfileRechargeCenterResponse { - wallet_balance: 0, mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { - status: "normal".to_string(), - tier: "normal".to_string(), + status: ProfileMembershipStatusToken::Normal, + plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, updated_at: None, @@ -1723,11 +2178,12 @@ mod tests { cycle_resets_at: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { @@ -1787,11 +2243,10 @@ mod tests { expiration_last_error: None, }; let center = ProfileRechargeCenterResponse { - wallet_balance: 0, mud_point_balance: empty_mud_point_balance(), membership: ProfileMembershipResponse { - status: "normal".to_string(), - tier: "normal".to_string(), + status: ProfileMembershipStatusToken::Normal, + plan: ProfileMembershipPlanToken::Normal, started_at: None, expires_at: None, updated_at: None, @@ -1799,11 +2254,12 @@ mod tests { cycle_resets_at: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: 30, + cycle_index: 1, + cycle_count: 1, + cycle_kind: ProfileMembershipCycleKindToken::Monthly, }, point_products: vec![], - membership_products: vec![], - benefits: vec![], + membership_plans: vec![], latest_order: None, has_points_recharged: false, daily_free_points: ProfileDailyFreePointsResponse { @@ -1954,4 +2410,150 @@ mod tests { json!("treasure_reward") ); } + + #[test] + fn membership_response_tokens_tolerate_unknown_values_from_newer_backend() { + // 旧 Rust 客户端(AGC Tauri shell)反序列化响应时,遇到后端新增的 token 取值 + // 不能整包失败;未知取值按 fail-open 兜底到最高已知档。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "paused", + "plan": "ultra", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "quarterly" + })) + .expect("unknown membership tokens must not fail the whole response"); + assert_eq!(membership.status, ProfileMembershipStatusToken::Active); + assert_eq!(membership.plan, ProfileMembershipPlanToken::Max); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "ultra", + "title": "Ultra", + "rank": 9, + "monthPriceCents": 0, + "yearPriceCents": 0, + "periodPoints": 0, + "modelAccess": "unlimited", + "concurrentJobLimit": 128, + "recommended": false, + "unlimitedConcurrency": true, + "enabled": true + })) + .expect("unknown plan tokens must not fail the whole response"); + assert_eq!(plan.plan, ProfileMembershipPlanToken::Max); + assert_eq!(plan.model_access, ProfileMembershipModelAccessToken::Full); + + // 已知取值仍然原样解析(兜底不吞掉合法值)。 + let known: ProfileMembershipStatusToken = + serde_json::from_value(json!("normal")).expect("known token should deserialize"); + assert_eq!(known, ProfileMembershipStatusToken::Normal); + } + + #[test] + fn membership_response_tolerates_fields_missing_from_older_backend() { + // 客户端先于服务端发布(滚动升级)时,旧服务端缺少本轮新增字段不能整包失败; + // 新增字段一律带默认值,这里锁住默认值的具体取值。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "active", + "plan": "plus", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0 + })) + .expect("older membership payload must still deserialize"); + assert_eq!(membership.cycle_index, 1); + assert_eq!(membership.cycle_count, 1); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let balance: ProfileMudPointBalanceResponse = serde_json::from_value(json!({ + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyResetsAt": null, + "permanentPoints": 0 + })) + .expect("older balance payload must still deserialize"); + assert_eq!(balance.monthly_points, 0); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "plus", + "title": "Plus", + "rank": 3, + "monthPriceCents": 100, + "yearPriceCents": 1000, + "periodPoints": 100, + "modelAccess": "full", + "concurrentJobLimit": 8, + "enabled": true + })) + .expect("older plan payload must still deserialize"); + assert!(!plan.recommended); + assert!(!plan.unlimited_concurrency); + + let center: ProfileRechargeCenterResponse = serde_json::from_value(json!({ + "mudPointBalance": { + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyPoints": 0, + "monthlyResetsAt": null, + "permanentPoints": 0 + }, + "membership": { + "status": "normal", + "plan": "normal", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "monthly" + }, + "pointProducts": [], + "latestOrder": null, + "hasPointsRecharged": false, + "dailyFreePoints": { + "dayKey": 0, + "grantedPoints": 20, + "remainingPoints": 20, + "resetsAt": "2026-07-12T16:00:00Z", + "updatedAt": "2026-07-12T00:00:00Z" + } + })) + .expect("older recharge center payload must still deserialize"); + assert!(center.membership_plans.is_empty()); + } + + #[test] + fn membership_request_tokens_stay_strict_for_unknown_values() { + // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。 + assert!( + serde_json::from_value::(json!({ + "targetPlan": "ultra" + })) + .is_err() + ); + } } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 0028767bf..76d607eea 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -77,8 +77,9 @@ use module_runtime::{ AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeProfileDashboardRecord, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeAdminListRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeAdminListRecord, + RuntimeProfileMembershipRecord, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRedeemCodeAdminListRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, @@ -92,6 +93,11 @@ use module_runtime::{ build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, + build_runtime_profile_membership_get_input, + build_runtime_profile_membership_plan_admin_list_input, + build_runtime_profile_membership_plan_admin_upsert_input, + build_runtime_profile_membership_record, build_runtime_profile_membership_upgrade_quote_input, + build_runtime_profile_membership_upgrade_quote_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_order_close_input, build_runtime_profile_recharge_order_create_input, @@ -230,6 +236,12 @@ pub enum SpacetimeClientError { Build(String), ConnectDropped, Procedure(String), + /// profile procedure 返回了带机器可读错误码的业务拒绝(会员 / 兑换码核销 / 后台兑换码管理 / + /// 后台邀请码管理)。 + ProcedureRejected { + code: module_runtime::RuntimeProfileProcedureRejectionCode, + message: String, + }, Runtime(String), Timeout(SpacetimeClientStage), } @@ -988,6 +1000,16 @@ impl SpacetimeClientError { Self::Procedure(message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string())) } + pub(crate) fn procedure_rejected( + code: impl Into, + message: Option, + ) -> Self { + Self::ProcedureRejected { + code: code.into(), + message: message.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + } + } + pub(crate) fn missing_snapshot(label: &'static str) -> Self { Self::Procedure(format!("SpacetimeDB procedure 未返回{label}")) } @@ -1070,6 +1092,10 @@ impl fmt::Display for SpacetimeClientError { Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => { f.write_str(message) } + Self::ProcedureRejected { code, message } => { + // `code` 是排障与审计要看的机器可读原因,Display 不能把它吞掉。 + write!(f, "[{}] {}", code.as_str(), message) + } Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"), Self::Timeout(stage) => { f.write_str(&format!("SpacetimeDB {} 调用超时", stage.as_str())) diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 37ece034a..af0c19b24 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -1,5 +1,19 @@ use super::*; +/// 会员 procedure 失败统一落错误:有机器可读 `error_code` 时走 `ProcedureRejected`,否则回退字符串错误。 +pub(crate) fn map_runtime_profile_membership_procedure_failure( + error_code: Option, + error_message: Option, +) -> SpacetimeClientError { + match error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_membership_error_code_back(code), + error_message, + ), + None => SpacetimeClientError::procedure_failed(error_message), + } +} + impl From for RuntimeProfileDashboardGetInput { fn from(input: module_runtime::RuntimeProfileDashboardGetInput) -> Self { Self { @@ -18,6 +32,14 @@ impl From } } +impl From for RuntimeProfileMembershipGetInput { + fn from(input: module_runtime::RuntimeProfileMembershipGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( value: crate::module_bindings::RuntimeProfileWalletLedgerSourceType, ) -> module_runtime::RuntimeProfileWalletLedgerSourceType { @@ -70,6 +92,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { module_runtime::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant => { + module_runtime::RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant + } crate::module_bindings::RuntimeProfileWalletLedgerSourceType::GamePurchase => { module_runtime::RuntimeProfileWalletLedgerSourceType::GamePurchase } @@ -170,7 +195,6 @@ impl From user_id: input.user_id, product_id: input.product_id, payment_channel: input.payment_channel, - created_at_micros: input.created_at_micros, } } } @@ -559,15 +583,8 @@ impl From 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), - membership_period_points: input.membership_period_points, - membership_period_days: input.membership_period_days, - membership_queue_limit: input.membership_queue_limit, - membership_discount_bps: input.membership_discount_bps, enabled: input.enabled, sort_order: input.sort_order, updated_at_micros: input.updated_at_micros, @@ -734,7 +751,10 @@ pub(crate) fn map_runtime_profile_recharge_center_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let snapshot = result @@ -746,6 +766,36 @@ pub(crate) fn map_runtime_profile_recharge_center_procedure_result( )) } +pub(crate) fn map_runtime_profile_membership_snapshot_procedure_result( + result: RuntimeProfileMembershipSnapshotProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile membership 快照"))?; + Ok(build_runtime_profile_membership_record( + map_runtime_profile_membership_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_agc_model_access_procedure_result( + result: RuntimeProfileAgcModelAccessProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile AGC model access 快照"))?; + Ok(module_runtime::RuntimeProfileAgcModelAccessSnapshot { + user_id: record.user_id, + plan: map_runtime_profile_membership_plan_back(record.plan), + model_access: map_runtime_profile_membership_model_access_back(record.model_access), + }) +} + pub(crate) fn map_runtime_profile_recharge_order_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result< @@ -756,7 +806,10 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result( SpacetimeClientError, > { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); } let center = result @@ -1063,7 +1116,14 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( result: RuntimeProfileRewardCodeRedeemProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + // 业务拒绝自带机器可读 error_code 时走 typed 变体;入参 / 钱包等错误仍按普通字符串错误返回。 + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_reward_code_redeem_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -1211,7 +1271,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( result: RuntimeProfileRedeemCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result @@ -1227,7 +1293,13 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( result: RuntimeProfileRedeemCodeAdminListProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_redeem_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileRedeemCodeAdminListRecord { @@ -1256,11 +1328,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( result: RuntimeProfileInviteCodeAdminProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } let snapshot = result.record.ok_or_else(|| { @@ -1276,7 +1350,13 @@ pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( result: RuntimeProfileInviteCodeAdminListProcedureResult, ) -> Result { if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); + return Err(match result.error_code { + Some(code) => SpacetimeClientError::procedure_rejected( + map_runtime_profile_invite_code_admin_error_code_back(code), + result.error_message, + ), + None => SpacetimeClientError::procedure_failed(result.error_message), + }); } Ok(RuntimeProfileInviteCodeAdminListRecord { @@ -1381,11 +1461,6 @@ pub(crate) fn map_runtime_profile_recharge_product_snapshot( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, } } @@ -1401,15 +1476,10 @@ pub(crate) fn map_runtime_profile_recharge_center_snapshot( .into_iter() .map(map_runtime_profile_recharge_product_snapshot) .collect(), - membership_products: snapshot - .membership_products + membership_plans: snapshot + .membership_plans .into_iter() - .map(map_runtime_profile_recharge_product_snapshot) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(map_runtime_profile_membership_benefit_snapshot) + .map(map_runtime_profile_membership_plan_record) .collect(), latest_order: snapshot .latest_order @@ -1434,11 +1504,6 @@ pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( duration_days: snapshot.duration_days, badge_label: snapshot.badge_label, description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - membership_period_points: snapshot.membership_period_points, - membership_period_days: snapshot.membership_period_days, - membership_queue_limit: snapshot.membership_queue_limit, - membership_discount_bps: snapshot.membership_discount_bps, enabled: snapshot.enabled, sort_order: snapshot.sort_order, created_by: snapshot.created_by, @@ -1448,19 +1513,19 @@ pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( } } -pub(crate) fn map_runtime_profile_membership_benefit_snapshot( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { - module_runtime::RuntimeProfileMembershipBenefitSnapshot { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - starter_value: snapshot.starter_value, - basic_value: snapshot.basic_value, - pro_value: snapshot.pro_value, - ultimate_value: snapshot.ultimate_value, +pub(crate) fn map_runtime_profile_membership_plan_record( + record: RuntimeProfileMembershipPlanRecord, +) -> module_runtime::RuntimeProfileMembershipPlanRecord { + module_runtime::RuntimeProfileMembershipPlanRecord { + plan: map_runtime_profile_membership_plan_back(record.plan), + title: record.title, + rank: record.rank, + month_price_cents: record.month_price_cents, + year_price_cents: record.year_price_cents, + period_points: record.period_points, + model_access: map_runtime_profile_membership_model_access_back(record.model_access), + concurrent_job_limit: record.concurrent_job_limit, + enabled: record.enabled, } } @@ -1470,7 +1535,7 @@ pub(crate) fn map_runtime_profile_membership_snapshot( module_runtime::RuntimeProfileMembershipSnapshot { user_id: snapshot.user_id, status: map_runtime_profile_membership_status_back(snapshot.status), - tier: map_runtime_profile_membership_tier_back(snapshot.tier), + plan: map_runtime_profile_membership_plan_back(snapshot.plan), started_at_micros: snapshot.started_at_micros, expires_at_micros: snapshot.expires_at_micros, updated_at_micros: snapshot.updated_at_micros, @@ -1478,7 +1543,9 @@ pub(crate) fn map_runtime_profile_membership_snapshot( cycle_resets_at_micros: snapshot.cycle_resets_at_micros, cycle_granted_points: snapshot.cycle_granted_points, cycle_remaining_points: snapshot.cycle_remaining_points, - cycle_period_days: snapshot.cycle_period_days, + cycle_index: snapshot.cycle_index, + cycle_count: snapshot.cycle_count, + cycle_kind: map_runtime_profile_membership_cycle_kind_back(snapshot.cycle_kind), } } @@ -1635,6 +1702,9 @@ pub(crate) fn map_runtime_profile_recharge_order_admin_entry_snapshot( .active_hold .map(map_runtime_profile_recharge_refund_hold_snapshot), wallet: map_runtime_profile_admin_wallet_snapshot(snapshot.wallet), + membership_change: snapshot + .membership_change + .map(map_runtime_profile_membership_order_change_snapshot), } } @@ -1956,37 +2026,330 @@ 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 { +pub(crate) fn map_runtime_profile_membership_plan( + value: module_runtime::RuntimeProfileMembershipPlan, +) -> crate::module_bindings::RuntimeProfileMembershipPlan { match value { - module_runtime::RuntimeProfileMembershipTier::Normal => { - crate::module_bindings::RuntimeProfileMembershipTier::Normal + module_runtime::RuntimeProfileMembershipPlan::Normal => { + crate::module_bindings::RuntimeProfileMembershipPlan::Normal } - module_runtime::RuntimeProfileMembershipTier::Month => { - crate::module_bindings::RuntimeProfileMembershipTier::Month + module_runtime::RuntimeProfileMembershipPlan::Starter => { + crate::module_bindings::RuntimeProfileMembershipPlan::Starter } - module_runtime::RuntimeProfileMembershipTier::Season => { - crate::module_bindings::RuntimeProfileMembershipTier::Season + module_runtime::RuntimeProfileMembershipPlan::Plus => { + crate::module_bindings::RuntimeProfileMembershipPlan::Plus } - module_runtime::RuntimeProfileMembershipTier::Year => { - crate::module_bindings::RuntimeProfileMembershipTier::Year + module_runtime::RuntimeProfileMembershipPlan::Pro => { + crate::module_bindings::RuntimeProfileMembershipPlan::Pro } - module_runtime::RuntimeProfileMembershipTier::Starter => { - crate::module_bindings::RuntimeProfileMembershipTier::Starter - } - module_runtime::RuntimeProfileMembershipTier::Basic => { - crate::module_bindings::RuntimeProfileMembershipTier::Basic - } - module_runtime::RuntimeProfileMembershipTier::Pro => { - crate::module_bindings::RuntimeProfileMembershipTier::Pro - } - module_runtime::RuntimeProfileMembershipTier::Ultimate => { - crate::module_bindings::RuntimeProfileMembershipTier::Ultimate + module_runtime::RuntimeProfileMembershipPlan::Max => { + crate::module_bindings::RuntimeProfileMembershipPlan::Max } } } +pub(crate) fn map_runtime_profile_membership_plan_back( + value: crate::module_bindings::RuntimeProfileMembershipPlan, +) -> module_runtime::RuntimeProfileMembershipPlan { + match value { + crate::module_bindings::RuntimeProfileMembershipPlan::Normal => { + module_runtime::RuntimeProfileMembershipPlan::Normal + } + crate::module_bindings::RuntimeProfileMembershipPlan::Starter => { + module_runtime::RuntimeProfileMembershipPlan::Starter + } + crate::module_bindings::RuntimeProfileMembershipPlan::Plus => { + module_runtime::RuntimeProfileMembershipPlan::Plus + } + crate::module_bindings::RuntimeProfileMembershipPlan::Pro => { + module_runtime::RuntimeProfileMembershipPlan::Pro + } + crate::module_bindings::RuntimeProfileMembershipPlan::Max => { + module_runtime::RuntimeProfileMembershipPlan::Max + } + } +} + +pub(crate) fn map_runtime_profile_membership_cycle_kind_back( + value: crate::module_bindings::RuntimeProfileMembershipCycleKind, +) -> module_runtime::RuntimeProfileMembershipCycleKind { + match value { + crate::module_bindings::RuntimeProfileMembershipCycleKind::Monthly => { + module_runtime::RuntimeProfileMembershipCycleKind::Monthly + } + crate::module_bindings::RuntimeProfileMembershipCycleKind::Yearly => { + module_runtime::RuntimeProfileMembershipCycleKind::Yearly + } + } +} + +pub(crate) fn map_runtime_profile_membership_change_kind_back( + value: crate::module_bindings::RuntimeProfileMembershipChangeKind, +) -> module_runtime::RuntimeProfileMembershipChangeKind { + match value { + crate::module_bindings::RuntimeProfileMembershipChangeKind::Purchase => { + module_runtime::RuntimeProfileMembershipChangeKind::Purchase + } + crate::module_bindings::RuntimeProfileMembershipChangeKind::Upgrade => { + module_runtime::RuntimeProfileMembershipChangeKind::Upgrade + } + } +} + +pub(crate) fn map_runtime_profile_membership_error_code_back( + value: crate::module_bindings::RuntimeProfileMembershipErrorCode, +) -> module_runtime::RuntimeProfileMembershipErrorCode { + match value { + crate::module_bindings::RuntimeProfileMembershipErrorCode::UpgradeRejected => { + module_runtime::RuntimeProfileMembershipErrorCode::UpgradeRejected + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::NotMember => { + module_runtime::RuntimeProfileMembershipErrorCode::NotMember + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::AlreadyActive => { + module_runtime::RuntimeProfileMembershipErrorCode::AlreadyActive + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::CycleKindLocked => { + module_runtime::RuntimeProfileMembershipErrorCode::CycleKindLocked + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipExpired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipExpired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::StateChanged => { + module_runtime::RuntimeProfileMembershipErrorCode::StateChanged + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanNotPurchasable => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanNotPurchasable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::PlanCatalogMissing => { + module_runtime::RuntimeProfileMembershipErrorCode::PlanCatalogMissing + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::ProductIdUnparseable => { + module_runtime::RuntimeProfileMembershipErrorCode::ProductIdUnparseable + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::MembershipProductRetired => { + module_runtime::RuntimeProfileMembershipErrorCode::MembershipProductRetired + } + crate::module_bindings::RuntimeProfileMembershipErrorCode::InvalidPlanConfig => { + module_runtime::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + } +} + +pub(crate) fn map_runtime_profile_reward_code_redeem_error_code_back( + value: crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode, +) -> module_runtime::RuntimeProfileRewardCodeRedeemErrorCode { + match value { + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotFound => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Disabled => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Disabled + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotStarted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::Expired => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::Expired + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser + } + crate::module_bindings::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward => { + module_runtime::RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileRedeemCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::NotFound + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired => { + module_runtime::RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired + } + } +} + +pub(crate) fn map_runtime_profile_invite_code_admin_error_code_back( + value: crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode, +) -> module_runtime::RuntimeProfileInviteCodeAdminErrorCode { + match value { + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InvalidField => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InvalidField + } + crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse => { + module_runtime::RuntimeProfileInviteCodeAdminErrorCode::InUse + } + } +} + +pub(crate) fn map_runtime_profile_membership_model_access( + value: module_runtime::RuntimeProfileMembershipModelAccess, +) -> crate::module_bindings::RuntimeProfileMembershipModelAccess { + match value { + module_runtime::RuntimeProfileMembershipModelAccess::Basic => { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Basic + } + module_runtime::RuntimeProfileMembershipModelAccess::Full => { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Full + } + } +} + +pub(crate) fn map_runtime_profile_membership_model_access_back( + value: crate::module_bindings::RuntimeProfileMembershipModelAccess, +) -> module_runtime::RuntimeProfileMembershipModelAccess { + match value { + crate::module_bindings::RuntimeProfileMembershipModelAccess::Basic => { + module_runtime::RuntimeProfileMembershipModelAccess::Basic + } + crate::module_bindings::RuntimeProfileMembershipModelAccess::Full => { + module_runtime::RuntimeProfileMembershipModelAccess::Full + } + } +} + +pub(crate) fn map_runtime_profile_membership_order_change_snapshot( + snapshot: RuntimeProfileMembershipOrderChangeSnapshot, +) -> module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { + module_runtime::RuntimeProfileMembershipOrderChangeSnapshot { + change_kind: map_runtime_profile_membership_change_kind_back(snapshot.change_kind), + plan_before: snapshot + .plan_before + .map(map_runtime_profile_membership_plan_back), + plan_after: snapshot + .plan_after + .map(map_runtime_profile_membership_plan_back), + cycle_kind_before: snapshot + .cycle_kind_before + .map(map_runtime_profile_membership_cycle_kind_back), + cycle_kind_after: snapshot + .cycle_kind_after + .map(map_runtime_profile_membership_cycle_kind_back), + period_points_before: snapshot.period_points_before, + period_points_after: snapshot.period_points_after, + granted_points_delta: snapshot.granted_points_delta, + cycle_index: snapshot.cycle_index, + expires_at_before_micros: snapshot.expires_at_before_micros, + expires_at_after_micros: snapshot.expires_at_after_micros, + cycle_resets_at_before_micros: snapshot.cycle_resets_at_before_micros, + cycle_resets_at_after_micros: snapshot.cycle_resets_at_after_micros, + price_breakdown_json: snapshot.price_breakdown_json, + } +} + +impl From + for RuntimeProfileMembershipUpgradeQuoteGetInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipUpgradeQuoteGetInput) -> Self { + Self { + user_id: input.user_id, + target_plan: map_runtime_profile_membership_plan(input.target_plan), + } + } +} + +impl From + for RuntimeProfileMembershipPlanAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipPlanAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileMembershipPlanAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileMembershipPlanAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + plan: map_runtime_profile_membership_plan(input.plan), + title: input.title, + month_price_cents: input.month_price_cents, + year_price_cents: input.year_price_cents, + period_points: input.period_points, + model_access: map_runtime_profile_membership_model_access(input.model_access), + concurrent_job_limit: input.concurrent_job_limit, + enabled: input.enabled, + updated_at_micros: input.updated_at_micros, + } + } +} + +pub(crate) fn map_runtime_profile_membership_upgrade_quote_procedure_result( + result: RuntimeProfileMembershipUpgradeQuoteProcedureResult, +) -> Result { + if !result.ok { + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); + } + + let quote = result + .quote + .ok_or_else(|| SpacetimeClientError::missing_snapshot("会员升级报价"))?; + + Ok(build_runtime_profile_membership_upgrade_quote_record( + module_runtime::RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: map_runtime_profile_membership_plan_back(quote.current_plan), + target_plan: map_runtime_profile_membership_plan_back(quote.target_plan), + cycle_kind: map_runtime_profile_membership_cycle_kind_back(quote.cycle_kind), + cycle_index: quote.cycle_index, + cycle_count: quote.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }, + )) +} + +pub(crate) fn map_runtime_profile_membership_plan_admin_list_procedure_result( + result: RuntimeProfileMembershipPlanAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(map_runtime_profile_membership_plan_record) + .collect()) +} + +pub(crate) fn map_runtime_profile_membership_plan_admin_procedure_result( + result: RuntimeProfileMembershipPlanAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(map_runtime_profile_membership_procedure_failure( + result.error_code, + result.error_message, + )); + } + + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("membership plan 快照"))?; + + Ok(map_runtime_profile_membership_plan_record(record)) +} + pub(crate) fn map_runtime_profile_membership_status_back( value: crate::module_bindings::RuntimeProfileMembershipStatus, ) -> module_runtime::RuntimeProfileMembershipStatus { @@ -2000,37 +2363,6 @@ pub(crate) fn map_runtime_profile_membership_status_back( } } -pub(crate) fn map_runtime_profile_membership_tier_back( - value: crate::module_bindings::RuntimeProfileMembershipTier, -) -> module_runtime::RuntimeProfileMembershipTier { - match value { - crate::module_bindings::RuntimeProfileMembershipTier::Normal => { - module_runtime::RuntimeProfileMembershipTier::Normal - } - crate::module_bindings::RuntimeProfileMembershipTier::Month => { - module_runtime::RuntimeProfileMembershipTier::Month - } - crate::module_bindings::RuntimeProfileMembershipTier::Season => { - module_runtime::RuntimeProfileMembershipTier::Season - } - crate::module_bindings::RuntimeProfileMembershipTier::Year => { - module_runtime::RuntimeProfileMembershipTier::Year - } - crate::module_bindings::RuntimeProfileMembershipTier::Starter => { - module_runtime::RuntimeProfileMembershipTier::Starter - } - crate::module_bindings::RuntimeProfileMembershipTier::Basic => { - module_runtime::RuntimeProfileMembershipTier::Basic - } - crate::module_bindings::RuntimeProfileMembershipTier::Pro => { - module_runtime::RuntimeProfileMembershipTier::Pro - } - crate::module_bindings::RuntimeProfileMembershipTier::Ultimate => { - module_runtime::RuntimeProfileMembershipTier::Ultimate - } - } -} - pub(crate) fn map_runtime_profile_recharge_order_status_back( value: crate::module_bindings::RuntimeProfileRechargeOrderStatus, ) -> module_runtime::RuntimeProfileRechargeOrderStatus { @@ -2204,3 +2536,80 @@ pub(crate) fn map_runtime_profile_feedback_status_back( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn admin_redeem_code_rejection_uses_typed_code() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("兑换码不存在".to_string()), + error_code: Some( + crate::module_bindings::RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + ), + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "not_found"); + assert_eq!(message, "兑换码不存在"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_redeem_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_redeem_code_admin_procedure_result(result) { + Err(SpacetimeClientError::Procedure(message)) => { + assert_eq!(message, "SpacetimeDB 事务失败"); + } + other => panic!("expected Procedure, got {other:?}"), + } + } + + #[test] + fn admin_invite_code_rejection_uses_typed_code() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("邀请码已被其他用户占用".to_string()), + error_code: Some(crate::module_bindings::RuntimeProfileInviteCodeAdminErrorCode::InUse), + }; + + match map_runtime_profile_invite_code_admin_procedure_result(result) { + Err(SpacetimeClientError::ProcedureRejected { code, message }) => { + assert_eq!(code.as_str(), "in_use"); + assert_eq!(message, "邀请码已被其他用户占用"); + } + other => panic!("expected ProcedureRejected, got {other:?}"), + } + } + + #[test] + fn admin_invite_code_without_error_code_stays_plain_procedure_error() { + let result = RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some("SpacetimeDB 事务失败".to_string()), + error_code: None, + }; + + match map_runtime_profile_invite_code_admin_procedure_result(result) { + Err(SpacetimeClientError::Procedure(message)) => { + assert_eq!(message, "SpacetimeDB 事务失败"); + } + other => panic!("expected Procedure, got {other:?}"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 119393fb1..8f03c3294 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -394,12 +394,67 @@ impl SpacetimeClient { .await } + /// 只读会员快照:不刷新钱包 / 账期,供后台详情等只读入口使用。 + pub async fn get_profile_membership_snapshot( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_membership_get_input(user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_membership_snapshot_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_membership_snapshot_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_membership_snapshot_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + /// 只读 AGC 模型权限:不刷新账期 / 免费点,供 LLM 代理热路径使用。 + pub async fn get_profile_agc_model_access( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_membership_get_input(user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_agc_model_access_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_agc_model_access_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_agc_model_access_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_profile_recharge_order( &self, user_id: String, product_id: String, payment_channel: String, - created_at_micros: i64, ) -> Result< ( RuntimeProfileRechargeCenterRecord, @@ -407,14 +462,10 @@ impl SpacetimeClient { ), SpacetimeClientError, > { - let procedure_input = build_runtime_profile_recharge_order_create_input( - user_id, - product_id, - payment_channel, - created_at_micros, - ) - .map_err(SpacetimeClientError::validation_failed)? - .into(); + let procedure_input = + build_runtime_profile_recharge_order_create_input(user_id, product_id, payment_channel) + .map_err(SpacetimeClientError::validation_failed)? + .into(); self.call_after_connect( "create_profile_recharge_order_and_return", @@ -1648,15 +1699,8 @@ impl SpacetimeClient { 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, - membership_period_points: u64, - membership_period_days: u32, - membership_queue_limit: u32, - membership_discount_bps: u32, enabled: bool, sort_order: i32, updated_at_micros: i64, @@ -1668,15 +1712,8 @@ impl SpacetimeClient { price_cents, kind, points_amount, - bonus_points, - duration_days, badge_label, description, - tier, - membership_period_points, - membership_period_days, - membership_queue_limit, - membership_discount_bps, enabled, sort_order, updated_at_micros, @@ -1705,6 +1742,117 @@ impl SpacetimeClient { .await } + /// 补差升级报价:只读,用来在付款前展示应付金额与补点;下单时后端会用同一函数重算。 + pub async fn get_profile_membership_upgrade_quote( + &self, + user_id: String, + target_plan: module_runtime::RuntimeProfileMembershipPlan, + ) -> Result + { + let procedure_input = + build_runtime_profile_membership_upgrade_quote_input(user_id, target_plan) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_membership_upgrade_quote", + move |connection, sender| { + connection + .procedures() + .get_profile_membership_upgrade_quote_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_upgrade_quote_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + /// 后台读取会员档位目录(价格与权益的唯一真相源)。 + pub async fn admin_list_profile_membership_plans( + &self, + admin_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = build_runtime_profile_membership_plan_admin_list_input(admin_user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "admin_list_profile_membership_plans", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_membership_plans_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_plan_admin_list_procedure_result, + ); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + /// 后台按档位改会员价格与权益;`rank` 由代码内目录决定,后台不能改。 + #[allow(clippy::too_many_arguments)] + pub async fn admin_upsert_profile_membership_plan( + &self, + admin_user_id: String, + plan: module_runtime::RuntimeProfileMembershipPlan, + title: String, + month_price_cents: u64, + year_price_cents: u64, + period_points: u64, + model_access: module_runtime::RuntimeProfileMembershipModelAccess, + concurrent_job_limit: u32, + enabled: bool, + updated_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_membership_plan_admin_upsert_input( + admin_user_id, + plan, + title, + month_price_cents, + year_price_cents, + period_points, + model_access, + concurrent_job_limit, + enabled, + updated_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "admin_upsert_profile_membership_plan", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_membership_plan_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_membership_plan_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-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 6481d2453..fe30047c0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -38,6 +38,7 @@ pub mod admin_initialize_profile_wallet_consumption_projections_and_return_proce pub mod admin_list_editor_assets_and_return_procedure; pub mod admin_list_editor_showcase_assets_and_return_procedure; pub mod admin_list_profile_invite_codes_procedure; +pub mod admin_list_profile_membership_plans_procedure; pub mod admin_list_profile_recharge_orders_and_return_procedure; pub mod admin_list_profile_recharge_products_procedure; pub mod admin_list_profile_redeem_codes_procedure; @@ -45,6 +46,7 @@ pub mod admin_list_profile_task_configs_procedure; pub mod admin_reconcile_profile_wallet_consumption_and_return_procedure; pub mod admin_review_editor_showcase_asset_and_return_procedure; pub mod admin_upsert_profile_invite_code_procedure; +pub mod admin_upsert_profile_membership_plan_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; @@ -512,7 +514,10 @@ pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; pub mod get_payment_order_and_return_procedure; pub mod get_payment_order_by_checkout_token_and_return_procedure; +pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_profile_dashboard_procedure; +pub mod get_profile_membership_snapshot_and_return_procedure; +pub mod get_profile_membership_upgrade_quote_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; pub mod get_profile_recharge_refund_and_return_procedure; @@ -626,6 +631,8 @@ pub mod profile_feedback_submission_table; pub mod profile_feedback_submission_type; pub mod profile_invite_code_table; pub mod profile_invite_code_type; +pub mod profile_membership_plan_table; +pub mod profile_membership_plan_type; pub mod profile_membership_table; pub mod profile_membership_type; pub mod profile_played_world_table; @@ -712,6 +719,8 @@ pub mod runtime_profile_admin_wallet_detail_procedure_result_type; pub mod runtime_profile_admin_wallet_get_input_type; pub mod runtime_profile_admin_wallet_procedure_result_type; pub mod runtime_profile_admin_wallet_snapshot_type; +pub mod runtime_profile_agc_model_access_procedure_result_type; +pub mod runtime_profile_agc_model_access_snapshot_type; pub mod runtime_profile_code_operation_snapshot_type; pub mod runtime_profile_daily_free_points_snapshot_type; pub mod runtime_profile_dashboard_get_input_type; @@ -722,15 +731,31 @@ pub mod runtime_profile_feedback_status_type; pub mod runtime_profile_feedback_submission_input_type; pub mod runtime_profile_feedback_submission_procedure_result_type; pub mod runtime_profile_feedback_submission_snapshot_type; +pub mod runtime_profile_invite_code_admin_error_code_type; pub mod runtime_profile_invite_code_admin_list_input_type; pub mod runtime_profile_invite_code_admin_list_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type; pub mod runtime_profile_invite_code_admin_upsert_input_type; pub mod runtime_profile_invite_code_snapshot_type; -pub mod runtime_profile_membership_benefit_snapshot_type; +pub mod runtime_profile_membership_change_kind_type; +pub mod runtime_profile_membership_cycle_kind_type; +pub mod runtime_profile_membership_error_code_type; +pub mod runtime_profile_membership_get_input_type; +pub mod runtime_profile_membership_model_access_type; +pub mod runtime_profile_membership_order_change_snapshot_type; +pub mod runtime_profile_membership_plan_admin_list_input_type; +pub mod runtime_profile_membership_plan_admin_list_procedure_result_type; +pub mod runtime_profile_membership_plan_admin_procedure_result_type; +pub mod runtime_profile_membership_plan_admin_upsert_input_type; +pub mod runtime_profile_membership_plan_record_type; +pub mod runtime_profile_membership_plan_type; +pub mod runtime_profile_membership_snapshot_procedure_result_type; pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_status_type; pub mod runtime_profile_membership_tier_type; +pub mod runtime_profile_membership_upgrade_quote_get_input_type; +pub mod runtime_profile_membership_upgrade_quote_procedure_result_type; +pub mod runtime_profile_membership_upgrade_quote_snapshot_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; @@ -783,12 +808,14 @@ pub mod runtime_profile_recharge_refund_recovery_status_type; pub mod runtime_profile_recharge_refund_snapshot_type; pub mod runtime_profile_recharge_refund_status_type; pub mod runtime_profile_redeem_code_admin_disable_input_type; +pub mod runtime_profile_redeem_code_admin_error_code_type; pub mod runtime_profile_redeem_code_admin_list_input_type; pub mod runtime_profile_redeem_code_admin_list_procedure_result_type; pub mod runtime_profile_redeem_code_admin_procedure_result_type; pub mod runtime_profile_redeem_code_admin_upsert_input_type; pub mod runtime_profile_redeem_code_mode_type; pub mod runtime_profile_redeem_code_snapshot_type; +pub mod runtime_profile_reward_code_redeem_error_code_type; pub mod runtime_profile_reward_code_redeem_input_type; pub mod runtime_profile_reward_code_redeem_procedure_result_type; pub mod runtime_profile_reward_code_redeem_snapshot_type; @@ -926,6 +953,7 @@ pub use admin_initialize_profile_wallet_consumption_projections_and_return_proce pub use admin_list_editor_assets_and_return_procedure::admin_list_editor_assets_and_return; pub use admin_list_editor_showcase_assets_and_return_procedure::admin_list_editor_showcase_assets_and_return; pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes; +pub use admin_list_profile_membership_plans_procedure::admin_list_profile_membership_plans; pub use admin_list_profile_recharge_orders_and_return_procedure::admin_list_profile_recharge_orders_and_return; 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; @@ -933,6 +961,7 @@ pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_confi pub use admin_reconcile_profile_wallet_consumption_and_return_procedure::admin_reconcile_profile_wallet_consumption_and_return; pub use admin_review_editor_showcase_asset_and_return_procedure::admin_review_editor_showcase_asset_and_return; pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; +pub use admin_upsert_profile_membership_plan_procedure::admin_upsert_profile_membership_plan; 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; @@ -1400,7 +1429,10 @@ pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_ pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; pub use get_payment_order_and_return_procedure::get_payment_order_and_return; pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; +pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; +pub use get_profile_membership_snapshot_and_return_procedure::get_profile_membership_snapshot_and_return; +pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; pub use get_profile_recharge_refund_and_return_procedure::get_profile_recharge_refund_and_return; @@ -1514,6 +1546,8 @@ pub use profile_feedback_submission_table::*; pub use profile_feedback_submission_type::ProfileFeedbackSubmission; pub use profile_invite_code_table::*; pub use profile_invite_code_type::ProfileInviteCode; +pub use profile_membership_plan_table::*; +pub use profile_membership_plan_type::ProfileMembershipPlan; pub use profile_membership_table::*; pub use profile_membership_type::ProfileMembership; pub use profile_played_world_table::*; @@ -1600,6 +1634,8 @@ pub use runtime_profile_admin_wallet_detail_procedure_result_type::RuntimeProfil pub use runtime_profile_admin_wallet_get_input_type::RuntimeProfileAdminWalletGetInput; pub use runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; pub use runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +pub use runtime_profile_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +pub use runtime_profile_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; pub use runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; pub use runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; @@ -1610,15 +1646,31 @@ pub use runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; pub use runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput; pub use runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult; pub use runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot; +pub use runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput; pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult; pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; -pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; +pub use runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +pub use runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +pub use runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; +pub use runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; +pub use runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +pub use runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; +pub use runtime_profile_membership_plan_admin_list_input_type::RuntimeProfileMembershipPlanAdminListInput; +pub use runtime_profile_membership_plan_admin_list_procedure_result_type::RuntimeProfileMembershipPlanAdminListProcedureResult; +pub use runtime_profile_membership_plan_admin_procedure_result_type::RuntimeProfileMembershipPlanAdminProcedureResult; +pub use runtime_profile_membership_plan_admin_upsert_input_type::RuntimeProfileMembershipPlanAdminUpsertInput; +pub use runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; +pub use runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; +pub use runtime_profile_membership_snapshot_procedure_result_type::RuntimeProfileMembershipSnapshotProcedureResult; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; pub use runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; +pub use runtime_profile_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +pub use runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; +pub use runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; @@ -1671,12 +1723,14 @@ pub use runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRech pub use runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; pub use runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; +pub use runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput; pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult; pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult; pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput; pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode; pub use runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; +pub use runtime_profile_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; pub use runtime_profile_reward_code_redeem_input_type::RuntimeProfileRewardCodeRedeemInput; pub use runtime_profile_reward_code_redeem_procedure_result_type::RuntimeProfileRewardCodeRedeemProcedureResult; pub use runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; @@ -1953,6 +2007,7 @@ pub struct DbUpdate { profile_feedback_submission: __sdk::TableUpdate, profile_invite_code: __sdk::TableUpdate, profile_membership: __sdk::TableUpdate, + profile_membership_plan: __sdk::TableUpdate, profile_played_world: __sdk::TableUpdate, profile_recharge_order: __sdk::TableUpdate, profile_recharge_order_expiration_schedule: @@ -2212,6 +2267,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_membership" => db_update .profile_membership .append(profile_membership_table::parse_table_update(table_update)?), + "profile_membership_plan" => db_update.profile_membership_plan.append( + profile_membership_plan_table::parse_table_update(table_update)?, + ), "profile_played_world" => db_update.profile_played_world.append( profile_played_world_table::parse_table_update(table_update)?, ), @@ -2665,6 +2723,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_membership, ) .with_updates_by_pk(|row| &row.user_id); + diff.profile_membership_plan = cache + .apply_diff_to_table::( + "profile_membership_plan", + &self.profile_membership_plan, + ) + .with_updates_by_pk(|row| &row.plan); diff.profile_played_world = cache .apply_diff_to_table::( "profile_played_world", @@ -3026,6 +3090,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_membership" => db_update .profile_membership .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_membership_plan" => db_update + .profile_membership_plan + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_played_world" => db_update .profile_played_world .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3318,6 +3385,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_membership" => db_update .profile_membership .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_membership_plan" => db_update + .profile_membership_plan + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_played_world" => db_update .profile_played_world .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3494,6 +3564,7 @@ pub struct AppliedDiff<'r> { profile_feedback_submission: __sdk::TableAppliedDiff<'r, ProfileFeedbackSubmission>, profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>, profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>, + profile_membership_plan: __sdk::TableAppliedDiff<'r, ProfileMembershipPlan>, profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>, profile_recharge_order_expiration_schedule: @@ -3837,6 +3908,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_membership, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_membership_plan", + &self.profile_membership_plan, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_played_world", &self.profile_played_world, @@ -4714,6 +4790,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_feedback_submission_table::register_table(client_cache); profile_invite_code_table::register_table(client_cache); profile_membership_table::register_table(client_cache); + profile_membership_plan_table::register_table(client_cache); profile_played_world_table::register_table(client_cache); profile_recharge_order_table::register_table(client_cache); profile_recharge_order_expiration_schedule_table::register_table(client_cache); @@ -4809,6 +4886,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_feedback_submission", "profile_invite_code", "profile_membership", + "profile_membership_plan", "profile_played_world", "profile_recharge_order", "profile_recharge_order_expiration_schedule", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_procedure.rs new file mode 100644 index 000000000..6f1dc3cd1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_membership_plans_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_membership_plan_admin_list_input_type::RuntimeProfileMembershipPlanAdminListInput; +use super::runtime_profile_membership_plan_admin_list_procedure_result_type::RuntimeProfileMembershipPlanAdminListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminListProfileMembershipPlansArgs { + pub input: RuntimeProfileMembershipPlanAdminListInput, +} + +impl __sdk::InModule for AdminListProfileMembershipPlansArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_list_profile_membership_plans`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_list_profile_membership_plans { + fn admin_list_profile_membership_plans( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + ) { + self.admin_list_profile_membership_plans_then(input, |_, _| {}); + } + + fn admin_list_profile_membership_plans_then( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_list_profile_membership_plans for super::RemoteProcedures { + fn admin_list_profile_membership_plans_then( + &self, + input: RuntimeProfileMembershipPlanAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileMembershipPlanAdminListProcedureResult>( + "admin_list_profile_membership_plans", + AdminListProfileMembershipPlansArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_procedure.rs new file mode 100644 index 000000000..3ff1b58f7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_membership_plan_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_membership_plan_admin_procedure_result_type::RuntimeProfileMembershipPlanAdminProcedureResult; +use super::runtime_profile_membership_plan_admin_upsert_input_type::RuntimeProfileMembershipPlanAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileMembershipPlanArgs { + pub input: RuntimeProfileMembershipPlanAdminUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileMembershipPlanArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_membership_plan`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_upsert_profile_membership_plan { + fn admin_upsert_profile_membership_plan( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + ) { + self.admin_upsert_profile_membership_plan_then(input, |_, _| {}); + } + + fn admin_upsert_profile_membership_plan_then( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_membership_plan for super::RemoteProcedures { + fn admin_upsert_profile_membership_plan_then( + &self, + input: RuntimeProfileMembershipPlanAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileMembershipPlanAdminProcedureResult>( + "admin_upsert_profile_membership_plan", + AdminUpsertProfileMembershipPlanArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs new file mode 100644 index 000000000..eca693a53 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +use super::runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileAgcModelAccessAndReturnArgs { + pub input: RuntimeProfileMembershipGetInput, +} + +impl __sdk::InModule for GetProfileAgcModelAccessAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_agc_model_access_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_agc_model_access_and_return { + fn get_profile_agc_model_access_and_return(&self, input: RuntimeProfileMembershipGetInput) { + self.get_profile_agc_model_access_and_return_then(input, |_, _| {}); + } + + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_agc_model_access_and_return for super::RemoteProcedures { + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileAgcModelAccessProcedureResult>( + "get_profile_agc_model_access_and_return", + GetProfileAgcModelAccessAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs new file mode 100644 index 000000000..0ea44fe05 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_snapshot_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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_get_input_type::RuntimeProfileMembershipGetInput; +use super::runtime_profile_membership_snapshot_procedure_result_type::RuntimeProfileMembershipSnapshotProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileMembershipSnapshotAndReturnArgs { + pub input: RuntimeProfileMembershipGetInput, +} + +impl __sdk::InModule for GetProfileMembershipSnapshotAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_membership_snapshot_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_membership_snapshot_and_return { + fn get_profile_membership_snapshot_and_return(&self, input: RuntimeProfileMembershipGetInput) { + self.get_profile_membership_snapshot_and_return_then(input, |_, _| {}); + } + + fn get_profile_membership_snapshot_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_membership_snapshot_and_return for super::RemoteProcedures { + fn get_profile_membership_snapshot_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileMembershipSnapshotProcedureResult>( + "get_profile_membership_snapshot_and_return", + GetProfileMembershipSnapshotAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_procedure.rs new file mode 100644 index 000000000..dc37950df --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_membership_upgrade_quote_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_membership_upgrade_quote_get_input_type::RuntimeProfileMembershipUpgradeQuoteGetInput; +use super::runtime_profile_membership_upgrade_quote_procedure_result_type::RuntimeProfileMembershipUpgradeQuoteProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileMembershipUpgradeQuoteArgs { + pub input: RuntimeProfileMembershipUpgradeQuoteGetInput, +} + +impl __sdk::InModule for GetProfileMembershipUpgradeQuoteArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_membership_upgrade_quote`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_membership_upgrade_quote { + fn get_profile_membership_upgrade_quote( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + ) { + self.get_profile_membership_upgrade_quote_then(input, |_, _| {}); + } + + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_membership_upgrade_quote for super::RemoteProcedures { + fn get_profile_membership_upgrade_quote_then( + &self, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileMembershipUpgradeQuoteProcedureResult>( + "get_profile_membership_upgrade_quote", + GetProfileMembershipUpgradeQuoteArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs new file mode 100644 index 000000000..b9560bde3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_table.rs @@ -0,0 +1,234 @@ +// 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_membership_plan_type::ProfileMembershipPlan; +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_membership_plan`. +/// +/// Obtain a handle from the [`ProfileMembershipPlanTableAccess::profile_membership_plan`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_membership_plan()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_membership_plan().on_insert(...)`. +pub struct ProfileMembershipPlanTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `profile_membership_plan`. +pub struct ProfileMembershipPlanTableAccessor; + +impl __sdk::TableAccessor for ProfileMembershipPlanTableAccessor { + type Row = ProfileMembershipPlan; + type Handle<'db> = ProfileMembershipPlanTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.profile_membership_plan() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_membership_plan`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileMembershipPlanTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileMembershipPlanTableHandle`], which mediates access to the table `profile_membership_plan`. + fn profile_membership_plan(&self) -> ProfileMembershipPlanTableHandle<'_>; +} + +impl ProfileMembershipPlanTableAccess for super::RemoteTables { + fn profile_membership_plan(&self) -> ProfileMembershipPlanTableHandle<'_> { + ProfileMembershipPlanTableHandle { + imp: self + .imp + .get_table::("profile_membership_plan"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileMembershipPlanInsertCallbackId(__sdk::CallbackId); +pub struct ProfileMembershipPlanDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for ProfileMembershipPlanTableHandle<'ctx> { + type Row = ProfileMembershipPlan; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for ProfileMembershipPlanTableHandle<'ctx> { + type Row = ProfileMembershipPlan; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileMembershipPlanInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanInsertCallbackId { + ProfileMembershipPlanInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileMembershipPlanInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileMembershipPlanDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanDeleteCallbackId { + ProfileMembershipPlanDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileMembershipPlanDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for ProfileMembershipPlanTableHandle<'ctx> { + type InsertCallbackId = ProfileMembershipPlanInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanInsertCallbackId { + ProfileMembershipPlanInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileMembershipPlanInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for ProfileMembershipPlanTableHandle<'ctx> { + type DeleteCallbackId = ProfileMembershipPlanDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanDeleteCallbackId { + ProfileMembershipPlanDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileMembershipPlanDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileMembershipPlanUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileMembershipPlanTableHandle<'ctx> { + type UpdateCallbackId = ProfileMembershipPlanUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanUpdateCallbackId { + ProfileMembershipPlanUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileMembershipPlanUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for ProfileMembershipPlanTableHandle<'ctx> { + type UpdateCallbackId = ProfileMembershipPlanUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileMembershipPlanUpdateCallbackId { + ProfileMembershipPlanUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileMembershipPlanUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `plan` unique index on the table `profile_membership_plan`, +/// which allows point queries on the field of the same name +/// via the [`ProfileMembershipPlanPlanUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_membership_plan().plan().find(...)`. +pub struct ProfileMembershipPlanPlanUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileMembershipPlanTableHandle<'ctx> { + /// Get a handle on the `plan` unique index on the table `profile_membership_plan`. + pub fn plan(&self) -> ProfileMembershipPlanPlanUnique<'ctx> { + ProfileMembershipPlanPlanUnique { + imp: self + .imp + .get_unique_constraint::("plan"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileMembershipPlanPlanUnique<'ctx> { + /// Find the subscribed row whose `plan` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &RuntimeProfileMembershipPlan) -> 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_membership_plan"); + _table.add_unique_constraint::("plan", |row| &row.plan); +} + +#[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 `ProfileMembershipPlan`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_membership_planQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileMembershipPlan`. + fn profile_membership_plan(&self) -> __sdk::__query_builder::Table; +} + +impl profile_membership_planQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_membership_plan(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_membership_plan") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs new file mode 100644 index 000000000..91d11708a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_plan_type.rs @@ -0,0 +1,92 @@ +// 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_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileMembershipPlan { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub rank: u8, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, + pub created_by: String, + pub created_at: __sdk::Timestamp, + pub updated_by: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileMembershipPlan { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileMembershipPlan`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileMembershipPlanCols { + pub plan: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub rank: __sdk::__query_builder::Col, + pub month_price_cents: __sdk::__query_builder::Col, + pub year_price_cents: __sdk::__query_builder::Col, + pub period_points: __sdk::__query_builder::Col, + pub model_access: + __sdk::__query_builder::Col, + pub concurrent_job_limit: __sdk::__query_builder::Col, + pub enabled: __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 ProfileMembershipPlan { + type Cols = ProfileMembershipPlanCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileMembershipPlanCols { + plan: __sdk::__query_builder::Col::new(table_name, "plan"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + rank: __sdk::__query_builder::Col::new(table_name, "rank"), + month_price_cents: __sdk::__query_builder::Col::new(table_name, "month_price_cents"), + year_price_cents: __sdk::__query_builder::Col::new(table_name, "year_price_cents"), + period_points: __sdk::__query_builder::Col::new(table_name, "period_points"), + model_access: __sdk::__query_builder::Col::new(table_name, "model_access"), + concurrent_job_limit: __sdk::__query_builder::Col::new( + table_name, + "concurrent_job_limit", + ), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + 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 `ProfileMembershipPlan`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileMembershipPlanIxCols { + pub plan: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileMembershipPlan { + type IxCols = ProfileMembershipPlanIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileMembershipPlanIxCols { + plan: __sdk::__query_builder::IxCol::new(table_name, "plan"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileMembershipPlan {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs index a06543e88..b2b9ff777 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_table.rs @@ -3,6 +3,8 @@ #![allow(unused, clippy::all)] use super::profile_membership_type::ProfileMembership; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs index 8d1757038..451462434 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_membership_type.rs @@ -4,6 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; @@ -21,6 +23,10 @@ pub struct ProfileMembership { pub cycle_granted_points: u64, pub cycle_remaining_points: u64, pub cycle_period_days: u32, + pub plan: RuntimeProfileMembershipPlan, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } impl __sdk::InModule for ProfileMembership { @@ -42,6 +48,11 @@ pub struct ProfileMembershipCols { pub cycle_granted_points: __sdk::__query_builder::Col, pub cycle_remaining_points: __sdk::__query_builder::Col, pub cycle_period_days: __sdk::__query_builder::Col, + pub plan: __sdk::__query_builder::Col, + pub cycle_index: __sdk::__query_builder::Col, + pub cycle_count: __sdk::__query_builder::Col, + pub cycle_kind: + __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ProfileMembership { @@ -65,6 +76,10 @@ impl __sdk::__query_builder::HasCols for ProfileMembership { "cycle_remaining_points", ), cycle_period_days: __sdk::__query_builder::Col::new(table_name, "cycle_period_days"), + plan: __sdk::__query_builder::Col::new(table_name, "plan"), + cycle_index: __sdk::__query_builder::Col::new(table_name, "cycle_index"), + cycle_count: __sdk::__query_builder::Col::new(table_name, "cycle_count"), + cycle_kind: __sdk::__query_builder::Col::new(table_name, "cycle_kind"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs index 3869911a9..ae9030d06 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_table.rs @@ -3,6 +3,9 @@ #![allow(unused, clippy::all)] use super::profile_recharge_order_type::ProfileRechargeOrder; +use super::runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs index f12c3f8d0..6b8d3bf72 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_type.rs @@ -4,6 +4,9 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; @@ -27,6 +30,20 @@ pub struct ProfileRechargeOrder { pub expiration_checked_at: Option<__sdk::Timestamp>, pub expiration_provider_state: Option, pub expiration_last_error: Option, + pub membership_change_kind: Option, + pub membership_plan_before: Option, + pub membership_plan_after: Option, + pub membership_cycle_kind_before: Option, + pub membership_cycle_kind_after: Option, + pub membership_period_points_before: Option, + pub membership_period_points_after: Option, + pub membership_granted_points_delta: u64, + pub membership_cycle_index: Option, + pub membership_expires_at_before: Option<__sdk::Timestamp>, + pub membership_expires_at_after: Option<__sdk::Timestamp>, + pub membership_cycle_resets_at_before: Option<__sdk::Timestamp>, + pub membership_cycle_resets_at_after: Option<__sdk::Timestamp>, + pub membership_price_breakdown_json: Option, } impl __sdk::InModule for ProfileRechargeOrder { @@ -58,6 +75,38 @@ pub struct ProfileRechargeOrderCols { pub expiration_provider_state: __sdk::__query_builder::Col>, pub expiration_last_error: __sdk::__query_builder::Col>, + pub membership_change_kind: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_plan_before: + __sdk::__query_builder::Col>, + pub membership_plan_after: + __sdk::__query_builder::Col>, + pub membership_cycle_kind_before: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_cycle_kind_after: __sdk::__query_builder::Col< + ProfileRechargeOrder, + Option, + >, + pub membership_period_points_before: + __sdk::__query_builder::Col>, + pub membership_period_points_after: + __sdk::__query_builder::Col>, + pub membership_granted_points_delta: __sdk::__query_builder::Col, + pub membership_cycle_index: __sdk::__query_builder::Col>, + pub membership_expires_at_before: + __sdk::__query_builder::Col>, + pub membership_expires_at_after: + __sdk::__query_builder::Col>, + pub membership_cycle_resets_at_before: + __sdk::__query_builder::Col>, + pub membership_cycle_resets_at_after: + __sdk::__query_builder::Col>, + pub membership_price_breakdown_json: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRechargeOrder { @@ -96,6 +145,62 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeOrder { table_name, "expiration_last_error", ), + membership_change_kind: __sdk::__query_builder::Col::new( + table_name, + "membership_change_kind", + ), + membership_plan_before: __sdk::__query_builder::Col::new( + table_name, + "membership_plan_before", + ), + membership_plan_after: __sdk::__query_builder::Col::new( + table_name, + "membership_plan_after", + ), + membership_cycle_kind_before: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_kind_before", + ), + membership_cycle_kind_after: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_kind_after", + ), + membership_period_points_before: __sdk::__query_builder::Col::new( + table_name, + "membership_period_points_before", + ), + membership_period_points_after: __sdk::__query_builder::Col::new( + table_name, + "membership_period_points_after", + ), + membership_granted_points_delta: __sdk::__query_builder::Col::new( + table_name, + "membership_granted_points_delta", + ), + membership_cycle_index: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_index", + ), + membership_expires_at_before: __sdk::__query_builder::Col::new( + table_name, + "membership_expires_at_before", + ), + membership_expires_at_after: __sdk::__query_builder::Col::new( + table_name, + "membership_expires_at_after", + ), + membership_cycle_resets_at_before: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_resets_at_before", + ), + membership_cycle_resets_at_after: __sdk::__query_builder::Col::new( + table_name, + "membership_cycle_resets_at_after", + ), + membership_price_breakdown_json: __sdk::__query_builder::Col::new( + table_name, + "membership_price_breakdown_json", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs new file mode 100644 index 000000000..f95d34ce8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_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_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs new file mode 100644 index 000000000..ecbf88e77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs @@ -0,0 +1,20 @@ +// 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_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs new file mode 100644 index 000000000..cd112299d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_error_code_type.rs @@ -0,0 +1,18 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileInviteCodeAdminErrorCode { + InvalidField, + + InUse, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs index db56e3c0f..359435447 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +use super::runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileInviteCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs index 70f543006..4acbdce5a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_invite_code_admin_error_code_type::RuntimeProfileInviteCodeAdminErrorCode; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs new file mode 100644 index 000000000..5c6d03e00 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_change_kind_type.rs @@ -0,0 +1,18 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipChangeKind { + Purchase, + + Upgrade, +} + +impl __sdk::InModule for RuntimeProfileMembershipChangeKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs new file mode 100644 index 000000000..7de7247b1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_cycle_kind_type.rs @@ -0,0 +1,18 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipCycleKind { + Monthly, + + Yearly, +} + +impl __sdk::InModule for RuntimeProfileMembershipCycleKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs new file mode 100644 index 000000000..b5ffe9ca4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_error_code_type.rs @@ -0,0 +1,36 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipErrorCode { + UpgradeRejected, + + NotMember, + + AlreadyActive, + + CycleKindLocked, + + MembershipExpired, + + StateChanged, + + PlanNotPurchasable, + + PlanCatalogMissing, + + ProductIdUnparseable, + + MembershipProductRetired, + + InvalidPlanConfig, +} + +impl __sdk::InModule for RuntimeProfileMembershipErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_input_type.rs new file mode 100644 index 000000000..3e1dbd0a8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_get_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 RuntimeProfileMembershipGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfileMembershipGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs new file mode 100644 index 000000000..10eac3cf3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_model_access_type.rs @@ -0,0 +1,18 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipModelAccess { + Basic, + + Full, +} + +impl __sdk::InModule for RuntimeProfileMembershipModelAccess { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs new file mode 100644 index 000000000..fde2b7c43 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_order_change_snapshot_type.rs @@ -0,0 +1,32 @@ +// 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_change_kind_type::RuntimeProfileMembershipChangeKind; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipOrderChangeSnapshot { + pub change_kind: RuntimeProfileMembershipChangeKind, + pub plan_before: Option, + pub plan_after: Option, + pub cycle_kind_before: Option, + pub cycle_kind_after: Option, + pub period_points_before: Option, + pub period_points_after: Option, + pub granted_points_delta: u64, + pub cycle_index: Option, + pub expires_at_before_micros: Option, + pub expires_at_after_micros: Option, + pub cycle_resets_at_before_micros: Option, + pub cycle_resets_at_after_micros: Option, + pub price_breakdown_json: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipOrderChangeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_input_type.rs new file mode 100644 index 000000000..9727106a1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_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 RuntimeProfileMembershipPlanAdminListInput { + pub admin_user_id: String, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_list_procedure_result_type.rs new file mode 100644 index 000000000..52c8aa417 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_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_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs new file mode 100644 index 000000000..db0ada966 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_procedure_result_type.rs @@ -0,0 +1,21 @@ +// 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_error_code_type::RuntimeProfileMembershipErrorCode; +use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, + pub error_code: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs new file mode 100644 index 000000000..c916c8adf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_admin_upsert_input_type.rs @@ -0,0 +1,27 @@ +// 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_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanAdminUpsertInput { + pub admin_user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs new file mode 100644 index 000000000..1d344391e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_record_type.rs @@ -0,0 +1,26 @@ +// 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_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipPlanRecord { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub rank: u8, + pub month_price_cents: u64, + pub year_price_cents: u64, + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, + pub enabled: bool, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlanRecord { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs new file mode 100644 index 000000000..9fc0c3c78 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_plan_type.rs @@ -0,0 +1,24 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileMembershipPlan { + Normal, + + Starter, + + Plus, + + Pro, + + Max, +} + +impl __sdk::InModule for RuntimeProfileMembershipPlan { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_procedure_result_type.rs new file mode 100644 index 000000000..6c9abd06a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_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_membership_snapshot_type::RuntimeProfileMembershipSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipSnapshotProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipSnapshotProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs index aff6da4ef..87eecbec4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_snapshot_type.rs @@ -4,15 +4,16 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; use super::runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; -use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct RuntimeProfileMembershipSnapshot { pub user_id: String, pub status: RuntimeProfileMembershipStatus, - pub tier: RuntimeProfileMembershipTier, + pub plan: RuntimeProfileMembershipPlan, pub started_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: Option, @@ -20,7 +21,9 @@ pub struct RuntimeProfileMembershipSnapshot { pub cycle_resets_at_micros: Option, pub cycle_granted_points: u64, pub cycle_remaining_points: u64, - pub cycle_period_days: u32, + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, } impl __sdk::InModule for RuntimeProfileMembershipSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs new file mode 100644 index 000000000..2d554d508 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_get_input_type.rs @@ -0,0 +1,18 @@ +// 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_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteGetInput { + pub user_id: String, + pub target_plan: RuntimeProfileMembershipPlan, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs new file mode 100644 index 000000000..37356951d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_procedure_result_type.rs @@ -0,0 +1,21 @@ +// 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_error_code_type::RuntimeProfileMembershipErrorCode; +use super::runtime_profile_membership_upgrade_quote_snapshot_type::RuntimeProfileMembershipUpgradeQuoteSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteProcedureResult { + pub ok: bool, + pub quote: Option, + pub error_message: Option, + pub error_code: Option, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs new file mode 100644 index 000000000..e3a9ddb6b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_upgrade_quote_snapshot_type.rs @@ -0,0 +1,28 @@ +// 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_cycle_kind_type::RuntimeProfileMembershipCycleKind; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileMembershipUpgradeQuoteSnapshot { + pub current_plan: RuntimeProfileMembershipPlan, + pub target_plan: RuntimeProfileMembershipPlan, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_index: u32, + pub cycle_count: u32, + pub amount_cents: u64, + pub granted_points_delta: u64, + pub monthly_balance_after: u64, + pub expires_at_micros: i64, + pub cycle_resets_at_micros: i64, + pub remaining_full_months: u32, +} + +impl __sdk::InModule for RuntimeProfileMembershipUpgradeQuoteSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs index e9e055e02..202c1a1a1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_membership_error_code_type::RuntimeProfileMembershipErrorCode; use super::runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; @@ -14,6 +15,7 @@ pub struct RuntimeProfileRechargeCenterProcedureResult { pub record: Option, pub order: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRechargeCenterProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs index 747739cc1..504e1269d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_center_snapshot_type.rs @@ -5,7 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; -use super::runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; +use super::runtime_profile_membership_plan_record_type::RuntimeProfileMembershipPlanRecord; use super::runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; use super::runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; @@ -17,8 +17,7 @@ pub struct RuntimeProfileRechargeCenterSnapshot { pub wallet_balance: u64, pub membership: RuntimeProfileMembershipSnapshot, pub point_products: Vec, - pub membership_products: Vec, - pub benefits: Vec, + pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, pub daily_free_points: RuntimeProfileDailyFreePointsSnapshot, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs index dcc036214..d5a26cf9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +use super::runtime_profile_membership_order_change_snapshot_type::RuntimeProfileMembershipOrderChangeSnapshot; use super::runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; use super::runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; @@ -18,6 +19,7 @@ pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { pub refunds: Vec, pub active_hold: Option, pub wallet: RuntimeProfileAdminWalletSnapshot, + pub membership_change: Option, } impl __sdk::InModule for RuntimeProfileRechargeOrderAdminEntrySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs index 7a4c7ee43..0697f06f2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_create_input_type.rs @@ -10,7 +10,6 @@ pub struct RuntimeProfileRechargeOrderCreateInput { pub user_id: String, pub product_id: String, pub payment_channel: String, - pub created_at_micros: i64, } impl __sdk::InModule for RuntimeProfileRechargeOrderCreateInput { 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 index 6713a169b..1a95d58aa 100644 --- 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 @@ -4,7 +4,6 @@ #![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)] @@ -16,15 +15,8 @@ pub struct RuntimeProfileRechargeProductAdminUpsertInput { 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 membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub updated_at_micros: i64, 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 index 04654580f..44904194e 100644 --- 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 @@ -4,7 +4,6 @@ #![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)] @@ -19,11 +18,6 @@ pub struct RuntimeProfileRechargeProductConfigSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, pub enabled: bool, pub sort_order: i32, pub created_by: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs index 9b70f76cd..9a2bd0234 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_product_snapshot_type.rs @@ -4,7 +4,6 @@ #![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)] @@ -19,11 +18,6 @@ pub struct RuntimeProfileRechargeProductSnapshot { pub duration_days: u32, pub badge_label: String, pub description: String, - pub tier: RuntimeProfileMembershipTier, - pub membership_period_points: u64, - pub membership_period_days: u32, - pub membership_queue_limit: u32, - pub membership_discount_bps: u32, } impl __sdk::InModule for RuntimeProfileRechargeProductSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs new file mode 100644 index 000000000..3a5e6fa15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_error_code_type.rs @@ -0,0 +1,20 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRedeemCodeAdminErrorCode { + NotFound, + + InvalidField, + + AllowedUsersRequired, +} + +impl __sdk::InModule for RuntimeProfileRedeemCodeAdminErrorCode { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs index 86f26def6..5515525bd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs @@ -5,6 +5,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; +use super::runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -14,6 +15,7 @@ pub struct RuntimeProfileRedeemCodeAdminListProcedureResult { pub entries: Vec, pub operations: Vec, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs index 62254ff97..ca771ad9a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_redeem_code_admin_error_code_type::RuntimeProfileRedeemCodeAdminErrorCode; use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeAdminProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs similarity index 51% rename from server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs rename to server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs index 675038d3f..949523f68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_membership_benefit_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_error_code_type.rs @@ -6,18 +6,23 @@ 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 RuntimeProfileMembershipBenefitSnapshot { - pub benefit_name: String, - pub normal_value: String, - pub month_value: String, - pub season_value: String, - pub year_value: String, - pub starter_value: String, - pub basic_value: String, - pub pro_value: String, - pub ultimate_value: String, +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRewardCodeRedeemErrorCode { + NotFound, + + Disabled, + + NotStarted, + + Expired, + + UsesExhausted, + + NotAllowedForUser, + + InvalidReward, } -impl __sdk::InModule for RuntimeProfileMembershipBenefitSnapshot { +impl __sdk::InModule for RuntimeProfileRewardCodeRedeemErrorCode { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs index dd8936d7e..13fb730fe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_reward_code_redeem_procedure_result_type.rs @@ -4,6 +4,7 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::runtime_profile_reward_code_redeem_error_code_type::RuntimeProfileRewardCodeRedeemErrorCode; use super::runtime_profile_reward_code_redeem_snapshot_type::RuntimeProfileRewardCodeRedeemSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -12,6 +13,7 @@ pub struct RuntimeProfileRewardCodeRedeemProcedureResult { pub ok: bool, pub record: Option, pub error_message: Option, + pub error_code: Option, } impl __sdk::InModule for RuntimeProfileRewardCodeRedeemProcedureResult { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index b60d7981b..37022b3e1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -40,6 +40,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { LlmRouterConsume, + MembershipUpgradeGrant, + GamePurchase, } diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 5e93b162c..0c7f63901 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -1,5 +1,6 @@ use crate::*; use std::cmp::Ordering; +use std::collections::HashMap; use std::ops::RangeFrom; const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending"; @@ -47,6 +48,11 @@ const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str = accessor = by_external_generation_job_owner_user_id, btree(columns = [owner_user_id]) ), + // 会员并发上限在认领事务里按账号现算 `status = running` 行数;不建计数表,`running` 行即真相。 + index( + accessor = by_external_generation_job_owner_status, + btree(columns = [owner_user_id, status]) + ), index( accessor = by_external_generation_job_cursor, btree(columns = [job_id, source_module]) @@ -879,10 +885,31 @@ fn claim_external_generation_jobs_tx( .then_with(|| left.job_id.cmp(&right.job_id)) }); + // TODO(并发认领公平性): `take(limit)` 只检查按 `available_at / created_at` 排序的前 `limit` 行, + // 而 `limit` 就是 worker 空闲槽数(api-server 传 `available`,池子默认 2 / release 8),被并发上限 + // 拦下或被 lease 耗尽终结的行只 `continue`,却已吃掉一个检查名额。若队头若干行都属已饱和账号, + // 本轮 `claimed` 可能为空,后面其它账号的可认领任务看不到 —— 跨账号队头阻塞;负载越高 `limit` + // 越小(7/8 worker 占用时 `limit = 1`)越容易触发。 + // 修法(未决,改前需确认单事务写集上界):遍历全部候选,成功认领一行才计数,`claimed.len() >= limit` + // 才 break,被跳过 / 终结的行不占名额,仍是全局 FIFO;若要「不同账号平均穿插」则需另做按账号轮转。 let mut claimed = Vec::new(); + let mut concurrency = ExternalGenerationOwnerConcurrencyCache::default(); for mut row in candidates.into_iter().take(limit) { if external_generation_job_has_exhausted_attempts(&row) { + let owner_user_id = row.owner_user_id.clone(); finalize_external_generation_job_after_lease_exhaustion(ctx, row, claim_time)?; + // 终结可能把 running 行改成 failed,账号计数缓存随之失效。 + concurrency.invalidate_running(&owner_user_id); + continue; + } + // 会员并发上限:只约束 `pending → running` 的认领。达上限的任务留在 `pending` 天然排队; + // 账号自己那条已过期的 `running` 回收不受限,否则超限账号的卡死任务永远无法回收。 + if !external_generation_owner_has_concurrency_capacity( + ctx, + &mut concurrency, + &row, + claim_time, + ) { continue; } let next_attempt = row.attempt.saturating_add(1); @@ -911,12 +938,102 @@ fn claim_external_generation_jobs_tx( Some(worker_id.clone()), claim_time, ); + // 本次认领把一行推进到 running,账号计数缓存失效,后续候选行重新现查。 + concurrency.invalidate_running(&row.owner_user_id); claimed.push(map_external_generation_job_row(row)); } Ok(claimed) } +/// 单次 claim 事务内按账号缓存有效并发上限与 running 行数。 +/// +/// 上限在一次事务内是常量;running 计数在账号的任一行被认领或终结后立即失效、下次重查, +/// 因此判定结果与逐候选行现查一致,只是把同一账号的重复查询折叠为一次。 +#[derive(Default)] +struct ExternalGenerationOwnerConcurrencyCache { + limits: HashMap, + running: HashMap, +} + +impl ExternalGenerationOwnerConcurrencyCache { + fn limit(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(limit) = self.limits.get(owner_user_id) { + return *limit; + } + let limit = crate::effective_profile_concurrent_job_limit(ctx, owner_user_id); + self.limits.insert(owner_user_id.to_string(), limit); + limit + } + + fn running_count(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(count) = self.running.get(owner_user_id) { + return *count; + } + let count = count_running_external_generation_jobs_for_owner(ctx, owner_user_id); + self.running.insert(owner_user_id.to_string(), count); + count + } + + /// 账号任一 `running` 行发生状态变化(认领 / 终结)后调用,保证下次读取重新现查。 + fn invalidate_running(&mut self, owner_user_id: &str) { + self.running.remove(owner_user_id); + } + + fn allows(&mut self, ctx: &ReducerContext, owner_user_id: &str, is_recycling: bool) -> bool { + let limit = self.limit(ctx, owner_user_id); + if is_unlimited_concurrency(limit) { + return true; + } + // 回收豁免不需要计数:自身过期 running 已计入在飞数,放行不会推高它。 + let running = if is_recycling { + 0 + } else { + self.running_count(ctx, owner_user_id) + }; + external_generation_claim_within_concurrency_limit(is_recycling, running, limit) + } +} + +/// 认领前的并发上限判定。 +/// +/// 计数只算 `status = running`(含 lease 过期待回收的 `expired_running`):`pending`(含延时重试) +/// 不占名额,失败退回 `pending` 会自然释放。`128` 哨兵由 [`is_unlimited_concurrency`] 解释。 +fn external_generation_owner_has_concurrency_capacity( + ctx: &ReducerContext, + cache: &mut ExternalGenerationOwnerConcurrencyCache, + row: &ExternalGenerationJob, + now: Timestamp, +) -> bool { + // 回收豁免:候选本身已是该账号过期的 `running`,它已计入在飞数;放行回收不会推高在飞任务数。 + let is_recycling = row.status == EXTERNAL_GENERATION_STATUS_RUNNING + && is_external_generation_job_claimable(row, now); + cache.allows(ctx, &row.owner_user_id, is_recycling) +} + +/// 并发上限判定(纯函数):`running_count >= limit` 时只放行对账号自身过期 `running` 的回收。 +fn external_generation_claim_within_concurrency_limit( + is_recycling: bool, + running_count: u32, + limit: u32, +) -> bool { + is_unlimited_concurrency(limit) || is_recycling || running_count < limit +} + +/// 账号当前在飞的 `running` 行数;事务内的认领写入会立即反映到下一次计数。 +fn count_running_external_generation_jobs_for_owner( + ctx: &ReducerContext, + owner_user_id: &str, +) -> u32 { + ctx.db + .external_generation_job() + .by_external_generation_job_owner_status() + .filter((owner_user_id, EXTERNAL_GENERATION_STATUS_RUNNING)) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + fn finalize_external_generation_job_after_lease_exhaustion( ctx: &ReducerContext, row: ExternalGenerationJob, @@ -3850,6 +3967,47 @@ mod tests { } } + #[test] + fn concurrency_limit_blocks_pending_at_limit_but_recycles_expired_running() { + // pending 在飞数达到上限后不再认领,任务保持 pending 排队。 + assert!(external_generation_claim_within_concurrency_limit( + false, 0, 1 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 1, 1 + )); + assert!(external_generation_claim_within_concurrency_limit( + false, 2, 3 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 3, 3 + )); + + // 回收账号自身过期的 running 不受上限限制,否则超限账号的卡死任务永远无法回收。 + assert!(external_generation_claim_within_concurrency_limit( + true, 3, 1 + )); + + // 128 哨兵表示不设上限。 + assert!(external_generation_claim_within_concurrency_limit( + false, + 10_000, + MEMBERSHIP_UNLIMITED_CONCURRENCY, + )); + } + + #[test] + fn owner_concurrency_cache_only_invalidates_running_count() { + let mut cache = ExternalGenerationOwnerConcurrencyCache::default(); + cache.running.insert("owner-a".to_string(), 2); + cache.limits.insert("owner-a".to_string(), 3); + // 认领 / 终结后 running 计数必须重查,否则会把上一行的变更带到下一候选行。 + cache.invalidate_running("owner-a"); + assert!(cache.running.get("owner-a").is_none()); + // 上限在一次事务内是常量,不受 running 计数失效影响。 + assert_eq!(cache.limits.get("owner-a"), Some(&3)); + } + fn micros(value: i64) -> Timestamp { Timestamp::from_micros_since_unix_epoch(value) } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 8d19a40db..206b50004 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -189,6 +189,7 @@ macro_rules! migration_tables { public_work_like, // profile_membership / profile_recharge_product_config carry membership-cycle point fields. profile_membership, + profile_membership_plan, profile_recharge_product_config, profile_recharge_order, profile_recharge_refund, diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 1d9e1771d..dcf494699 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -22,6 +22,204 @@ const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; const PROFILE_WALLET_REFUND_OUTBOX_STATUS_PENDING: &str = "pending"; const PROFILE_WALLET_REFUND_OUTBOX_MAX_BATCH_SIZE: u32 = 100; +/// 会员 / 充值 procedure 的错误载体。 +/// +/// 会员领域拒绝携带机器可读 [`RuntimeProfileMembershipErrorCode`];其它来源的错误(身份校验、 +/// 钱包溢出、订单不存在等)保持普通字符串,透传时 `error_code` 为 `null`。 +enum ProfileMembershipProcedureError { + Membership(RuntimeProfileMembershipDomainError), + Plain(String), +} + +impl From for ProfileMembershipProcedureError { + fn from(error: RuntimeProfileMembershipDomainError) -> Self { + Self::Membership(error) + } +} + +impl From for ProfileMembershipProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileMembershipProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileMembershipProcedureError { + fn into_parts(self) -> (String, Option) { + match self { + Self::Membership(error) => (error.message, Some(error.code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 兑换码核销 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRewardCodeRedeemErrorCode`];入参、钱包等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员链路同一约定)。 +enum ProfileRewardCodeRedeemProcedureError { + Classified(RuntimeProfileRewardCodeRedeemErrorCode, String), + Plain(String), +} + +impl From for ProfileRewardCodeRedeemProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRewardCodeRedeemProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRewardCodeRedeemProcedureError { + fn classified( + code: RuntimeProfileRewardCodeRedeemErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 把兑换码校验的字段错误收敛成机器可读错误码;未列出的错误按普通错误处理(`error_code = null`)。 +fn reward_code_redeem_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRewardCodeRedeemProcedureError { + let code = match error { + RuntimeProfileFieldError::RedeemCodeDisabled => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Disabled) + } + RuntimeProfileFieldError::RedeemCodeNotStarted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotStarted) + } + RuntimeProfileFieldError::RedeemCodeExpired => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::Expired) + } + RuntimeProfileFieldError::RedeemCodeUsesExhausted => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted) + } + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser) + } + RuntimeProfileFieldError::InvalidRedeemCodeReward => { + Some(RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward) + } + _ => None, + }; + match code { + Some(code) => ProfileRewardCodeRedeemProcedureError::Classified(code, error.to_string()), + None => ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()), + } +} + +/// 后台兑换码管理 procedure 的错误载体。 +/// +/// 业务拒绝携带机器可读 [`RuntimeProfileRedeemCodeAdminErrorCode`];入参、系统等其它错误保持 +/// 普通字符串,透传时 `error_code` 为 `null`(与会员 / 核销链路同一约定)。 +enum ProfileRedeemCodeAdminProcedureError { + Classified(RuntimeProfileRedeemCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileRedeemCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileRedeemCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileRedeemCodeAdminProcedureError { + fn classified( + code: RuntimeProfileRedeemCodeAdminErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 后台兑换码的字段校验错误统一归入 `invalid_field`,保证入参拒绝也落 400 而不是 502。 +fn redeem_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileRedeemCodeAdminProcedureError { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + +/// 后台邀请码管理 procedure 的错误载体。 +/// +/// 与 [`ProfileRedeemCodeAdminProcedureError`] 同一约定:业务拒绝带机器可读 +/// [`RuntimeProfileInviteCodeAdminErrorCode`],其它错误保持普通字符串。 +enum ProfileInviteCodeAdminProcedureError { + Classified(RuntimeProfileInviteCodeAdminErrorCode, String), + Plain(String), +} + +impl From for ProfileInviteCodeAdminProcedureError { + fn from(message: String) -> Self { + Self::Plain(message) + } +} + +impl From<&str> for ProfileInviteCodeAdminProcedureError { + fn from(message: &str) -> Self { + Self::Plain(message.to_string()) + } +} + +impl ProfileInviteCodeAdminProcedureError { + fn classified( + code: RuntimeProfileInviteCodeAdminErrorCode, + message: impl Into, + ) -> Self { + Self::Classified(code, message.into()) + } + + fn into_parts(self) -> (String, Option) { + match self { + Self::Classified(code, message) => (message, Some(code)), + Self::Plain(message) => (message, None), + } + } +} + +/// 后台邀请码的字段校验错误统一归入 `invalid_field`,保证入参拒绝也落 400 而不是 502。 +fn invite_code_admin_error_from_field_error( + error: RuntimeProfileFieldError, +) -> ProfileInviteCodeAdminProcedureError { + ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InvalidField, + error.to_string(), + ) +} + #[spacetimedb::table(accessor = profile_dashboard_state)] pub struct ProfileDashboardState { #[primary_key] @@ -440,6 +638,7 @@ pub struct ProfileMembership { #[primary_key] pub(crate) user_id: String, pub(crate) status: RuntimeProfileMembershipStatus, + // 已退役:旧档位身份列,仅为兼容存量 schema 保留,运行时不读不写。 pub(crate) tier: RuntimeProfileMembershipTier, pub(crate) started_at: Timestamp, pub(crate) expires_at: Timestamp, @@ -452,8 +651,18 @@ pub struct ProfileMembership { pub(crate) cycle_granted_points: u64, #[default(0u64)] pub(crate) cycle_remaining_points: u64, + // 已退役:旧固定 30 天周期列,仅为兼容存量 schema 保留,运行时不读不写。 #[default(30u32)] pub(crate) cycle_period_days: u32, + // 末位追加:档位身份与自然月期数。 + #[default(RuntimeProfileMembershipPlan::Normal)] + pub(crate) plan: RuntimeProfileMembershipPlan, + #[default(0u32)] + pub(crate) cycle_index: u32, + #[default(1u32)] + pub(crate) cycle_count: u32, + #[default(RuntimeProfileMembershipCycleKind::Monthly)] + pub(crate) cycle_kind: RuntimeProfileMembershipCycleKind, } #[spacetimedb::table(accessor = profile_recharge_product_config)] @@ -469,6 +678,7 @@ pub struct ProfileRechargeProductConfig { pub(crate) duration_days: u32, pub(crate) badge_label: String, pub(crate) description: String, + // 已退役:旧会员档位列,仅为兼容存量 schema 保留,运行时不读不写。 pub(crate) tier: RuntimeProfileMembershipTier, pub(crate) enabled: bool, pub(crate) sort_order: i32, @@ -476,6 +686,7 @@ pub struct ProfileRechargeProductConfig { pub(crate) created_at: Timestamp, pub(crate) updated_by: String, pub(crate) updated_at: Timestamp, + // 已退役:旧会员商品权益列,仅为兼容存量 schema 保留,运行时不读不写。 #[default(0u64)] pub(crate) membership_period_points: u64, #[default(0u32)] @@ -486,6 +697,25 @@ pub struct ProfileRechargeProductConfig { pub(crate) membership_discount_bps: u32, } +#[spacetimedb::table(accessor = profile_membership_plan)] +#[derive(Clone)] +pub struct ProfileMembershipPlan { + #[primary_key] + pub(crate) plan: RuntimeProfileMembershipPlan, + pub(crate) title: String, + pub(crate) rank: u8, + pub(crate) month_price_cents: u64, + pub(crate) year_price_cents: u64, + pub(crate) period_points: u64, + pub(crate) model_access: RuntimeProfileMembershipModelAccess, + pub(crate) concurrent_job_limit: u32, + pub(crate) enabled: bool, + 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])), @@ -520,6 +750,35 @@ pub struct ProfileRechargeOrder { pub(crate) expiration_provider_state: Option, #[default(None::)] pub(crate) expiration_last_error: Option, + // 末位追加:会员购买 / 升级的变更快照,人工复核退款时据此还原变更前后的权益。 + #[default(None::)] + pub(crate) membership_change_kind: Option, + #[default(None::)] + pub(crate) membership_plan_before: Option, + #[default(None::)] + pub(crate) membership_plan_after: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_before: Option, + #[default(None::)] + pub(crate) membership_cycle_kind_after: Option, + #[default(None::)] + pub(crate) membership_period_points_before: Option, + #[default(None::)] + pub(crate) membership_period_points_after: Option, + #[default(0u64)] + pub(crate) membership_granted_points_delta: u64, + #[default(None::)] + pub(crate) membership_cycle_index: Option, + #[default(None::)] + pub(crate) membership_expires_at_before: Option, + #[default(None::)] + pub(crate) membership_expires_at_after: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_before: Option, + #[default(None::)] + pub(crate) membership_cycle_resets_at_after: Option, + #[default(None::)] + pub(crate) membership_price_breakdown_json: Option, } #[spacetimedb::table( @@ -979,7 +1238,13 @@ pub fn admin_list_profile_task_configs( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminListInput, ) -> RuntimeProfileTaskConfigAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_task_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult { ok: true, entries, @@ -998,7 +1263,13 @@ pub fn admin_upsert_profile_task_config( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminUpsertInput, ) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1017,7 +1288,13 @@ pub fn admin_disable_profile_task_config( ctx: &mut ProcedureContext, input: RuntimeProfileTaskConfigAdminDisableInput, ) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + disable_profile_task_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1036,7 +1313,13 @@ pub fn admin_get_profile_wallet_config( ctx: &mut ProcedureContext, input: RuntimeProfileWalletConfigAdminGetInput, ) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| get_profile_wallet_config_snapshot(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + get_profile_wallet_config_snapshot(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1055,7 +1338,13 @@ pub fn admin_upsert_profile_wallet_config( ctx: &mut ProcedureContext, input: RuntimeProfileWalletConfigAdminUpsertInput, ) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_wallet_config_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_wallet_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { ok: true, record: Some(record), @@ -1074,7 +1363,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_recharge_product_config_snapshots(tx, input.clone()) + }) { Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult { ok: true, entries, @@ -1093,7 +1388,13 @@ 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())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_recharge_product_config_record(tx, input.clone()) + }) { Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult { ok: true, record: Some(record), @@ -1107,6 +1408,100 @@ pub fn admin_upsert_profile_recharge_product( } } +// 会员档位目录的后台读写:后台按档位直接改价与改权益,充值中心与下单重算都以这张表为准。 +#[spacetimedb::procedure] +pub fn admin_list_profile_membership_plans( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipPlanAdminListInput, +) -> RuntimeProfileMembershipPlanAdminListProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + list_profile_membership_plan_records(tx, input.clone()) + }) { + Ok(entries) => RuntimeProfileMembershipPlanAdminListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileMembershipPlanAdminListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_membership_plan( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipPlanAdminUpsertInput, +) -> RuntimeProfileMembershipPlanAdminProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + upsert_profile_membership_plan_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { + Ok(record) => RuntimeProfileMembershipPlanAdminProcedureResult { + ok: true, + record: Some(record), + error_message: None, + error_code: None, + }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipPlanAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } + } +} + +// 补差升级报价:只读,下单时后端会用同一函数重算并落订单快照,前端展示的数字不是真相源。 +#[spacetimedb::procedure] +pub fn get_profile_membership_upgrade_quote( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> RuntimeProfileMembershipUpgradeQuoteProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_upgrade_quote_input( + input.user_id.clone(), + input.target_plan, + ) + .map_err(|error| error.to_string())?; + build_profile_membership_upgrade_quote_record(tx, validated) + .map_err(ProfileMembershipProcedureError::from) + }) { + Ok(quote) => RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: true, + quote: Some(quote), + error_message: None, + error_code: None, + }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileMembershipUpgradeQuoteProcedureResult { + ok: false, + quote: None, + error_message: Some(message), + error_code, + } + } + } +} + // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 #[spacetimedb::procedure] pub fn grant_new_user_registration_wallet_reward( @@ -1439,40 +1834,136 @@ pub fn get_profile_recharge_center( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeCenterGetInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_center_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok(record) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: None, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } + } +} + +/// 只读会员快照:不刷新钱包 / 账期、不写库,供后台详情等只读入口使用。 +#[spacetimedb::procedure] +pub fn get_profile_membership_snapshot_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipGetInput, +) -> RuntimeProfileMembershipSnapshotProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_get_input(input.user_id.clone()) + .map_err(|error| error.to_string())?; + Ok(build_profile_membership_snapshot_readonly( + tx, + &validated.user_id, + )) + }) { + Ok(record) => RuntimeProfileMembershipSnapshotProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileMembershipSnapshotProcedureResult { ok: false, record: None, - order: None, error_message: Some(message), }, } } +/// 账号当前有效的 AGC 模型权限:只读账期投影 + 档位目录 `model_access`,不刷新、不写库。 +/// +/// 供 api-server 的 LLM 代理热路径使用;缺会员行 / 已过期按 `Normal`,缺目录行失败关闭到 `Basic`。 +#[spacetimedb::procedure] +pub fn get_profile_agc_model_access_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipGetInput, +) -> RuntimeProfileAgcModelAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_get_input(input.user_id.clone()) + .map_err(|error| error.to_string())?; + Ok(build_profile_agc_model_access_snapshot_readonly( + tx, + &validated.user_id, + )) + }) { + Ok(record) => RuntimeProfileAgcModelAccessProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileAgcModelAccessProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +/// 纯读解析:用只读账期投影拿当前档位,再取档位目录的 `model_access`;缺目录行失败关闭到 `Basic`。 +fn build_profile_agc_model_access_snapshot_readonly( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileAgcModelAccessSnapshot { + let membership = build_profile_membership_snapshot_readonly(ctx, user_id); + let model_access = membership_plan_row(ctx, membership.plan) + .map(|row| row.model_access) + .unwrap_or(RuntimeProfileMembershipModelAccess::Basic); + RuntimeProfileAgcModelAccessSnapshot { + user_id: membership.user_id, + plan: membership.plan, + model_access, + } +} + #[spacetimedb::procedure] pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderGetInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + get_profile_recharge_order_snapshot(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1487,13 +1978,18 @@ pub fn create_profile_recharge_order_and_return( record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -1508,19 +2004,25 @@ pub fn mark_profile_recharge_order_paid_and_return( tx, caller, )?; mark_profile_recharge_order_paid_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -2171,19 +2673,27 @@ pub fn close_profile_recharge_order_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderCloseInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) { + match ctx.try_with_tx(|tx| { + close_profile_recharge_order_record(tx, input.clone()) + .map_err(ProfileMembershipProcedureError::from) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), order: Some(order), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRechargeCenterProcedureResult { + ok: false, + record: None, + order: None, + error_message: Some(message), + error_code, + } + } } } @@ -2257,12 +2767,17 @@ pub fn redeem_profile_reward_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRewardCodeRedeemProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2276,12 +2791,17 @@ pub fn admin_upsert_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2295,12 +2815,17 @@ pub fn admin_disable_profile_redeem_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2315,13 +2840,18 @@ pub fn admin_list_profile_redeem_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileRedeemCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -2335,12 +2865,17 @@ pub fn admin_upsert_profile_invite_code( ok: true, record: Some(record), error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminProcedureResult { + ok: false, + record: None, + error_message: Some(message), + error_code, + } + } } } @@ -2355,13 +2890,18 @@ pub fn admin_list_profile_invite_codes( entries, operations, error_message: None, + error_code: None, }, - Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, + Err(error) => { + let (message, error_code) = error.into_parts(); + RuntimeProfileInviteCodeAdminListProcedureResult { + ok: false, + entries: Vec::new(), + operations: Vec::new(), + error_message: Some(message), + error_code, + } + } } } @@ -2843,6 +3383,48 @@ mod tests { } } + #[test] + fn reward_code_redeem_field_errors_map_to_typed_codes() { + for (error, expected) in [ + ( + RuntimeProfileFieldError::RedeemCodeDisabled, + RuntimeProfileRewardCodeRedeemErrorCode::Disabled, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotStarted, + RuntimeProfileRewardCodeRedeemErrorCode::NotStarted, + ), + ( + RuntimeProfileFieldError::RedeemCodeExpired, + RuntimeProfileRewardCodeRedeemErrorCode::Expired, + ), + ( + RuntimeProfileFieldError::RedeemCodeUsesExhausted, + RuntimeProfileRewardCodeRedeemErrorCode::UsesExhausted, + ), + ( + RuntimeProfileFieldError::RedeemCodeNotAllowedForUser, + RuntimeProfileRewardCodeRedeemErrorCode::NotAllowedForUser, + ), + ( + RuntimeProfileFieldError::InvalidRedeemCodeReward, + RuntimeProfileRewardCodeRedeemErrorCode::InvalidReward, + ), + ] { + let (message, code) = + reward_code_redeem_error_from_field_error(error.clone()).into_parts(); + + assert_eq!(code, Some(expected), "{message}"); + assert_eq!(message, error.to_string()); + } + + // 未列入兑换码拒绝的错误(如入参校验)必须保持无码,避免把基础设施错误误判成业务 400。 + let (_, code) = + reward_code_redeem_error_from_field_error(RuntimeProfileFieldError::MissingUserId) + .into_parts(); + assert_eq!(code, None); + } + #[test] fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() { let mut entries = vec![ @@ -3263,6 +3845,20 @@ mod tests { expiration_checked_at: None, expiration_provider_state: None, expiration_last_error: None, + membership_change_kind: None, + membership_plan_before: None, + membership_plan_after: None, + membership_cycle_kind_before: None, + membership_cycle_kind_after: None, + membership_period_points_before: None, + membership_period_points_after: None, + membership_granted_points_delta: 0, + membership_cycle_index: None, + membership_expires_at_before: None, + membership_expires_at_after: None, + membership_cycle_resets_at_before: None, + membership_cycle_resets_at_after: None, + membership_price_breakdown_json: None, } } @@ -3593,24 +4189,26 @@ mod tests { } #[test] - fn point_recharge_display_is_resolved_per_product() { + fn point_recharge_display_has_no_first_charge_bonus_anywhere() { 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, 90); - assert_eq!(untouched.badge_label, "首充加赠"); - assert_eq!(untouched.description, "首充加赠90泥点"); + assert_eq!(products.len(), 6); + for product in &products { + assert_eq!(product.bonus_points, 0, "六档充点商品都没有首充赠送"); + assert_eq!(product.badge_label, ""); + assert_eq!(product.price_cents, product.points_amount * 10); + let displayed = resolve_profile_recharge_product_display(product.clone(), true); + assert_eq!(displayed.bonus_points, 0); + assert_eq!(displayed.badge_label, ""); + assert_eq!(displayed.description, product.title); + } + assert_eq!(products[0].product_id, "points_60"); + assert_eq!(products[5].product_id, "points_3280"); } #[test] - fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() { + fn legacy_default_recharge_products_migrate_without_first_charge_bonus() { let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); + // 旧播种行:面额赠送 +「首充双倍」标签 +「首充送N泥点」说明。 let build_row = |points_amount: u64| ProfileRechargeProductConfig { product_id: format!("points_{points_amount}"), title: format!("{points_amount}泥点"), @@ -3621,6 +4219,7 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: format!("首充送{points_amount}泥点"), + // 已退役列:不参与运行态,写固定默认值即可。 tier: RuntimeProfileMembershipTier::Normal, enabled: true, sort_order: 0, @@ -3628,34 +4227,58 @@ mod tests { created_at: timestamp, updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), updated_at: timestamp, + // 已退役列:不参与运行态,写固定默认值即可。 membership_period_points: 0, membership_period_days: 0, membership_queue_limit: 0, membership_discount_bps: 0, }; - assert_eq!( - resolve_default_point_product_migration(&build_row(180)), - Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - ); - assert_eq!( - resolve_default_point_product_migration(&build_row(1_280)), - Some(DefaultPointProductMigration { - bonus_points: 1_280, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - ); + let migration = + resolve_default_point_product_migration(&build_row(180)).expect("旧播种行应被迁移"); + assert_eq!(migration.bonus_points, 0); + assert_eq!(migration.badge_label, ""); + assert_eq!(migration.description, "180泥点"); + assert!(migration.enabled); + // 中文注释:¥128 / ¥328 两档旧版本是停用的首充行,上一版迁移把赠送保留成面额。 + let disabled_seed = ProfileRechargeProductConfig { + bonus_points: 1_280, + badge_label: "首充双倍".to_string(), + enabled: false, + ..build_row(1_280) + }; + let migration = resolve_default_point_product_migration(&disabled_seed) + .expect("停用的 ¥128 档应被启用"); + assert!(migration.enabled); + assert_eq!(migration.bonus_points, 0); + + // 中文注释:上一版迁移已把 180 / 300 / 680 改成在售的「首充加赠」行,也要归一。 + let first_charge_bonus_seed = ProfileRechargeProductConfig { + bonus_points: 90, + badge_label: "首充加赠".to_string(), + description: "首充加赠90泥点".to_string(), + enabled: true, + ..build_row(180) + }; + let migration = resolve_default_point_product_migration(&first_charge_bonus_seed) + .expect("「首充加赠」行应被归一"); + assert!(migration.enabled); + assert_eq!(migration.bonus_points, 0); + assert_eq!(migration.badge_label, ""); + assert_eq!(migration.description, "180泥点"); + + // 中文注释:后台改过的行(updated_by 已变)一律不动。 let mut admin_row = build_row(300); admin_row.updated_by = "admin-1".to_string(); - assert_eq!(resolve_default_point_product_migration(&admin_row), None); + assert!(resolve_default_point_product_migration(&admin_row).is_none()); + + // 中文注释:已经迁移过的行不重复迁移。 + let mut migrated_row = build_row(680); + migrated_row.bonus_points = 0; + migrated_row.badge_label = String::new(); + migrated_row.description = "680泥点".to_string(); + assert!(resolve_default_point_product_migration(&migrated_row).is_none()); } #[test] @@ -3916,173 +4539,202 @@ mod tests { } #[test] - fn membership_upgrade_amount_only_charges_price_difference() { - assert_eq!(membership_upgrade_amount_cents(6_990, 1_990), 5_000); - assert_eq!(membership_upgrade_amount_cents(19_990, 6_990), 13_000); - } - - #[test] - fn legacy_membership_tiers_share_new_pricing_ranks() { + fn membership_plan_window_recomputes_each_period_from_the_original_anchor() { + // 中文注释:1/31 开通 → 第 1 期到 2/28,第 2 期到 3/31;每期都从原始锚点重算。 + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Month), - RuntimeProfileMembershipTier::Starter, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Season), - RuntimeProfileMembershipTier::Basic, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Year), - RuntimeProfileMembershipTier::Pro, - ); - } - - #[test] - fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() { - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Ultimate, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Starter, + membership_cycle_window(anchor, 1), + ( + beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"), + beijing_local_micros(2026, 2, 28, 0).expect("测试用的北京本地时间应当合法") ) - .is_err() ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Basic, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Season, - ) - .is_err() - ); - } - - #[test] - fn legacy_membership_migration_renews_and_preserves_cycle_timing() { - let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS); - let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS); - let current_reset_at = - Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS); - let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS); - let row = apply_active_membership_renew_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Month, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Starter, - 30, - purchased_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter); assert_eq!( - row.expires_at, - Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS), + membership_cycle_window(anchor, 2), + ( + beijing_local_micros(2026, 2, 28, 0).expect("测试用的北京本地时间应当合法"), + beijing_local_micros(2026, 3, 31, 0).expect("测试用的北京本地时间应当合法") + ) ); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 90); - assert_eq!(row.cycle_granted_points, 0); - assert_eq!(row.cycle_remaining_points, 0); - assert_eq!(row.updated_at, purchased_at); } #[test] - fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() { - let started_at = Timestamp::from_micros_since_unix_epoch(1_000); - let expires_at = Timestamp::from_micros_since_unix_epoch(90_000); - let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000); - let updated_at = Timestamp::from_micros_since_unix_epoch(2_000); - let (row, period_points_delta) = apply_active_membership_upgrade_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Basic, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 800, - cycle_remaining_points: 120, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Pro, - 7, - 2_500, - updated_at, - ); + fn membership_cycle_advance_only_writes_when_a_new_period_starts() { + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); + let day = PROFILE_RUNTIME_DAY_MICROS; + let row = || ProfileMembership { + user_id: "user-1".to_string(), + status: RuntimeProfileMembershipStatus::Active, + // 已退役列:不参与运行态,写固定默认值即可。 + tier: RuntimeProfileMembershipTier::Normal, + started_at: Timestamp::from_micros_since_unix_epoch(anchor), + expires_at: Timestamp::from_micros_since_unix_epoch(anchor + 400 * day), + updated_at: Timestamp::from_micros_since_unix_epoch(anchor), + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch(anchor)), + cycle_resets_at: Some(Timestamp::from_micros_since_unix_epoch(anchor + 28 * day)), + cycle_granted_points: 400, + cycle_remaining_points: 120, + // 已退役列:不参与运行态,写固定默认值即可。 + cycle_period_days: 30, + plan: RuntimeProfileMembershipPlan::Starter, + cycle_index: 1, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + }; - assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro); - assert_eq!(row.expires_at, expires_at); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 7); - assert_eq!(row.cycle_granted_points, 2_500); - assert_eq!(row.cycle_remaining_points, 1_820); - assert_eq!(row.updated_at, updated_at); - assert_eq!(period_points_delta, 1_700); + let same_period = advance_profile_membership_cycle_to( + row(), + Timestamp::from_micros_since_unix_epoch(anchor + 10 * day), + 400, + ); + assert!(!same_period.advanced, "仍在本期时不应写账本"); + assert_eq!(same_period.row.cycle_index, 1); + assert_eq!(same_period.row.cycle_remaining_points, 120); + + // 期号水位为 0(新列默认值)但账期窗口已存在:只补水期号,不重发本期额度。 + let mut stale_watermark = row(); + stale_watermark.cycle_index = 0; + let backfilled = advance_profile_membership_cycle_to( + stale_watermark, + Timestamp::from_micros_since_unix_epoch(anchor + 10 * day), + 400, + ); + assert!(!backfilled.advanced, "期号水位缺失不应被当成换期"); + assert_eq!(backfilled.row.cycle_index, 1); + assert_eq!(backfilled.row.cycle_remaining_points, 120); + + let next_period = advance_profile_membership_cycle_to( + row(), + Timestamp::from_micros_since_unix_epoch(anchor + 28 * day), + 400, + ); + assert!(next_period.advanced); + assert_eq!(next_period.previous_remaining_points, 120); + assert_eq!(next_period.row.cycle_index, 2); + assert_eq!(next_period.row.cycle_granted_points, 400); + assert_eq!(next_period.row.cycle_remaining_points, 400); + } + + #[test] + fn membership_order_snapshot_is_frozen_into_the_order_and_read_back() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + let snapshot = ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan: RuntimeProfileMembershipPlan::Max, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + period_points: 400, + grant_points: 150, + plan_before: Some(RuntimeProfileMembershipPlan::Plus), + cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), + period_points_before: Some(250), + cycle_index_before: Some(3), + cycle_resets_at_before_micros: Some(1_700_000_000_000), + amount_cents: 9_900, + price_breakdown_json: "{\"orderAmountCents\":9900}".to_string(), + }; + apply_profile_membership_order_snapshot_to_order(&mut order, &snapshot); + + let settlement = frozen_profile_membership_settlement(&order).expect("应读回冻结快照"); + assert_eq!( + settlement.change_kind, + RuntimeProfileMembershipChangeKind::Upgrade + ); + assert_eq!(settlement.plan, RuntimeProfileMembershipPlan::Max); + assert_eq!( + settlement.cycle_kind, + RuntimeProfileMembershipCycleKind::Yearly + ); + assert_eq!(settlement.period_points, 400); + assert_eq!(settlement.grant_points, 150); + assert_eq!( + settlement.plan_before, + Some(RuntimeProfileMembershipPlan::Plus) + ); + assert_eq!(settlement.period_points_before, Some(250)); + assert_eq!(settlement.cycle_index_before, Some(3)); + assert_eq!( + settlement.cycle_resets_at_before_micros, + Some(1_700_000_000_000) + ); + assert_eq!( + settlement.price_breakdown_json, + "{\"orderAmountCents\":9900}" + ); + } + + #[test] + fn membership_order_without_frozen_snapshot_falls_back_to_recompute() { + let mut order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + order.kind = RuntimeProfileRechargeProductKind::Membership; + // 中文注释:建单时没有冻结快照的历史订单返回 None,调用方会回退到按目录重算。 + assert!(frozen_profile_membership_settlement(&order).is_none()); + } + + #[test] + fn frozen_membership_cycle_identity_rejects_advanced_cycle() { + let anchor = beijing_local_micros(2026, 1, 31, 0).expect("测试用的北京本地时间应当合法"); + let day = PROFILE_RUNTIME_DAY_MICROS; + let row = ProfileMembership { + user_id: "user-1".to_string(), + status: RuntimeProfileMembershipStatus::Active, + tier: RuntimeProfileMembershipTier::Normal, + started_at: Timestamp::from_micros_since_unix_epoch(anchor), + expires_at: Timestamp::from_micros_since_unix_epoch(anchor + 400 * day), + updated_at: Timestamp::from_micros_since_unix_epoch(anchor), + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch(anchor)), + cycle_resets_at: Some(Timestamp::from_micros_since_unix_epoch(anchor + 28 * day)), + cycle_granted_points: 400, + cycle_remaining_points: 120, + cycle_period_days: 30, + plan: RuntimeProfileMembershipPlan::Starter, + cycle_index: 2, + cycle_count: 12, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + }; + let settlement = + |cycle_index_before, cycle_resets_at_before_micros| ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan: RuntimeProfileMembershipPlan::Pro, + cycle_kind: RuntimeProfileMembershipCycleKind::Yearly, + period_points: 600, + grant_points: 150, + plan_before: Some(RuntimeProfileMembershipPlan::Starter), + cycle_kind_before: Some(RuntimeProfileMembershipCycleKind::Yearly), + period_points_before: Some(400), + cycle_index_before, + cycle_resets_at_before_micros, + price_breakdown_json: "{}".to_string(), + }; + + assert!(frozen_membership_cycle_matches( + &settlement(Some(2), Some(anchor + 28 * day)), + &row + )); + // 中文注释:期号或刷新时刻任一被推进都视为账期已变化,拒绝旧升级单补点。 + assert!(!frozen_membership_cycle_matches( + &settlement(Some(3), Some(anchor + 28 * day)), + &row + )); + assert!(!frozen_membership_cycle_matches( + &settlement(Some(2), Some(anchor + 60 * day)), + &row + )); + // 中文注释:历史重算订单没有冻结账期身份,跳过校验。 + assert!(frozen_membership_cycle_matches( + &settlement(None, None), + &row + )); + // 中文注释:存量的 `cycle_index == 0` 迁移行与投影的 `max(1)` 归一值等价, + // 不能因为「原始行还是 0」把建单快照 `Some(1)` 判成账期已变化。 + let legacy_row = ProfileMembership { + cycle_index: 0, + ..row + }; + assert!(frozen_membership_cycle_matches( + &settlement(Some(1), Some(anchor + 28 * day)), + &legacy_row + )); } #[test] @@ -4650,67 +5302,102 @@ fn create_profile_recharge_order_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_create_input( input.user_id, input.product_id, input.payment_channel, - input.created_at_micros, ) .map_err(|error| error.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 product = resolve_enabled_profile_recharge_order_product(ctx, &validated_input.product_id) + .ok_or_else(|| { + let message = "recharge.product_id 不存在或已下架"; + if parse_runtime_profile_membership_product_id(&validated_input.product_id).is_some() { + ProfileMembershipProcedureError::Membership( + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipProductRetired, + message, + ), + ) + } else { + ProfileMembershipProcedureError::Plain(message.to_string()) + } + })?; + // 中文注释:建单时刻一律取服务端事务时间,procedure 入参不再携带时间; + // 否则客户端可把时间设到会员到期前夕,压低升级补差金额。 + let created_at_micros = ctx.timestamp.to_micros_since_unix_epoch(); + let created_at = Timestamp::from_micros_since_unix_epoch(created_at_micros); let should_settle_immediately = validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; - let amount_cents = resolve_profile_recharge_order_amount_cents( - ctx, - &validated_input.user_id, - &product, - created_at, - )?; - let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately { - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( - ctx, - &validated_input.user_id, - &product, - validated_input.created_at_micros, - created_at, - )?; - ( - RuntimeProfileRechargeOrderStatus::Paid, - Some(created_at), - points_delta, - membership_expires_at, - ) - } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) + // 会员订单:建单时冻结档位、价格与补点快照,之后支付只认这份快照(改价 / 下架都不影响)。 + let membership_snapshot = match product.kind { + RuntimeProfileRechargeProductKind::Membership => { + Some(build_profile_membership_order_snapshot( + ctx, + &validated_input.user_id, + &product.product_id, + created_at, + )?) + } + RuntimeProfileRechargeProductKind::Points => None, }; + let amount_cents = membership_snapshot + .as_ref() + .map(|snapshot| snapshot.amount_cents) + .unwrap_or(product.price_cents); + let order_id = build_runtime_profile_recharge_order_id( + &validated_input.user_id, + created_at_micros, + &product.product_id, + ); - let order = ProfileRechargeOrder { - order_id: build_runtime_profile_recharge_order_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), + let mut order = ProfileRechargeOrder { + order_id, user_id: validated_input.user_id.clone(), product_id: product.product_id.clone(), product_title: product.title.clone(), kind: product.kind, amount_cents, - status, + status: RuntimeProfileRechargeOrderStatus::Pending, payment_channel: validated_input.payment_channel, - paid_at, + paid_at: None, provider_transaction_id: None, created_at, - points_delta, - membership_expires_at, + points_delta: 0, + membership_expires_at: None, expired_at: None, expiration_checked_at: None, expiration_provider_state: None, expiration_last_error: None, + membership_change_kind: None, + membership_plan_before: None, + membership_plan_after: None, + membership_cycle_kind_before: None, + membership_cycle_kind_after: None, + membership_period_points_before: None, + membership_period_points_after: None, + membership_granted_points_delta: 0, + membership_cycle_index: None, + membership_expires_at_before: None, + membership_expires_at_after: None, + membership_cycle_resets_at_before: None, + membership_cycle_resets_at_after: None, + membership_price_breakdown_json: None, }; + if let Some(snapshot) = membership_snapshot.as_ref() { + apply_profile_membership_order_snapshot_to_order(&mut order, snapshot); + } + + if should_settle_immediately { + let purchase = apply_profile_recharge_purchase(ctx, &order, created_at)?; + order.status = RuntimeProfileRechargeOrderStatus::Paid; + order.paid_at = Some(created_at); + order.points_delta = purchase.points_delta; + order.membership_expires_at = purchase.membership_expires_at; + apply_profile_membership_change_to_order(&mut order, purchase.membership_change); + } + ctx.db.profile_recharge_order().insert(order.clone()); if order.status == RuntimeProfileRechargeOrderStatus::Pending && should_schedule_profile_recharge_order_expiration(&order.payment_channel) @@ -4769,7 +5456,7 @@ fn mark_profile_recharge_order_paid_record( RuntimeProfileRechargeCenterSnapshot, RuntimeProfileRechargeOrderSnapshot, ), - String, + ProfileMembershipProcedureError, > { let validated_input = build_runtime_profile_recharge_order_paid_input( input.order_id, @@ -4802,19 +5489,14 @@ fn mark_profile_recharge_order_paid_record( order.status, RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired ) { - return Err("profile_recharge_order 当前状态不能确认支付".to_string()); + return Err(ProfileMembershipProcedureError::Plain( + "profile_recharge_order 当前状态不能确认支付".to_string(), + )); } - 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( - ctx, - &order.user_id, - &product, - order.created_at.to_micros_since_unix_epoch(), - paid_at, - )?; + // 支付结算只认订单建单时冻结的会员快照;充点订单按商品配置发放。 + let purchase = apply_profile_recharge_purchase(ctx, &order, paid_at)?; ctx.db .profile_recharge_order() @@ -4823,8 +5505,9 @@ fn mark_profile_recharge_order_paid_record( order.status = RuntimeProfileRechargeOrderStatus::Paid; order.paid_at = Some(paid_at); order.provider_transaction_id = validated_input.provider_transaction_id; - order.points_delta = points_delta; - order.membership_expires_at = membership_expires_at; + order.points_delta = purchase.points_delta; + order.membership_expires_at = purchase.membership_expires_at; + apply_profile_membership_change_to_order(&mut order, purchase.membership_change); ctx.db.profile_recharge_order().insert(order.clone()); delete_profile_recharge_order_expiration_task(ctx, &order.order_id); @@ -4937,9 +5620,41 @@ fn build_profile_recharge_order_admin_entry_snapshot( active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id) .map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)), wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id), + membership_change: build_profile_membership_change_snapshot_from_order_row(row), } } +/// 订单行的会员变更快照 → 只读投影;非会员订单(`membership_change_kind` 为空)返回 `None`。 +fn build_profile_membership_change_snapshot_from_order_row( + row: &ProfileRechargeOrder, +) -> Option { + let change_kind = row.membership_change_kind?; + Some(RuntimeProfileMembershipOrderChangeSnapshot { + change_kind, + plan_before: row.membership_plan_before, + plan_after: row.membership_plan_after, + cycle_kind_before: row.membership_cycle_kind_before, + cycle_kind_after: row.membership_cycle_kind_after, + period_points_before: row.membership_period_points_before, + period_points_after: row.membership_period_points_after, + granted_points_delta: row.membership_granted_points_delta, + cycle_index: row.membership_cycle_index, + expires_at_before_micros: row + .membership_expires_at_before + .map(|value| value.to_micros_since_unix_epoch()), + expires_at_after_micros: row + .membership_expires_at_after + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros: row + .membership_cycle_resets_at_before + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_after_micros: row + .membership_cycle_resets_at_after + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: row.membership_price_breakdown_json.clone(), + }) +} + fn preview_profile_recharge_refund_hold( ctx: &ReducerContext, input: RuntimeProfileRechargeRefundHoldPreviewInput, @@ -6859,18 +7574,26 @@ fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order .delete(&order_id.to_string()); } +/// 充值 / 会员购买落账结果;会员订单额外带权益变更快照供人工复核。 +struct ProfileRechargePurchaseOutcome { + points_delta: i64, + membership_expires_at: Option, + membership_change: Option, +} + fn apply_profile_recharge_purchase( ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_created_at_micros: i64, + order: &ProfileRechargeOrder, paid_at: Timestamp, -) -> Result<(i64, Option), String> { - match product.kind { +) -> Result { + let user_id = order.user_id.as_str(); + match order.kind { RuntimeProfileRechargeProductKind::Points => { + let product = profile_recharge_product_by_id(ctx, &order.product_id) + .ok_or_else(|| "recharge.product_id 不存在".to_string())?; 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); + resolve_runtime_profile_points_recharge_delta(&product, has_recharged); apply_profile_wallet_delta( ctx, user_id, @@ -6878,24 +7601,60 @@ fn apply_profile_recharge_purchase( RuntimeProfileWalletLedgerSourceType::PointsRecharge, &build_runtime_profile_recharge_wallet_ledger_id( user_id, - order_created_at_micros, + order.created_at.to_micros_since_unix_epoch(), &product.product_id, ), paid_at, )?; - Ok((points_delta as i64, None)) + Ok(ProfileRechargePurchaseOutcome { + points_delta: points_delta as i64, + membership_expires_at: None, + membership_change: None, + }) } RuntimeProfileRechargeProductKind::Membership => { - let purchase_result = - apply_profile_membership_purchase(ctx, user_id, product, paid_at)?; - Ok(( - purchase_result.period_points_delta as i64, - Some(purchase_result.expires_at), - )) + let outcome = apply_profile_membership_purchase(ctx, user_id, order, paid_at)?; + Ok(ProfileRechargePurchaseOutcome { + points_delta: outcome.period_points_delta as i64, + membership_expires_at: Some(outcome.expires_at), + membership_change: Some(outcome.change), + }) } } } +/// 把会员权益变更快照落到订单行的末位快照列上。 +fn apply_profile_membership_change_to_order( + order: &mut ProfileRechargeOrder, + change: Option, +) { + let Some(change) = change else { + return; + }; + order.membership_change_kind = Some(change.change_kind); + order.membership_plan_before = change.plan_before; + order.membership_plan_after = change.plan_after; + order.membership_cycle_kind_before = change.cycle_kind_before; + order.membership_cycle_kind_after = change.cycle_kind_after; + order.membership_period_points_before = change.period_points_before; + order.membership_period_points_after = change.period_points_after; + order.membership_granted_points_delta = change.granted_points_delta; + order.membership_cycle_index = change.cycle_index; + order.membership_expires_at_before = change + .expires_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_expires_at_after = change + .expires_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_before = change + .cycle_resets_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_cycle_resets_at_after = change + .cycle_resets_at_after_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_price_breakdown_json = change.price_breakdown_json; +} + fn submit_profile_feedback_record( ctx: &ReducerContext, input: RuntimeProfileFeedbackSubmissionInput, @@ -7055,13 +7814,13 @@ fn redeem_profile_referral_invite_code_record( fn redeem_profile_reward_code_record( ctx: &ReducerContext, input: RuntimeProfileRewardCodeRedeemInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_reward_code_redeem_input( input.user_id, input.code, input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(|error| ProfileRewardCodeRedeemProcedureError::Plain(error.to_string()))?; let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); let user_id = validated_input.user_id; let code = validated_input.code; @@ -7070,7 +7829,12 @@ fn redeem_profile_reward_code_record( .profile_redeem_code() .code() .find(&code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRewardCodeRedeemProcedureError::classified( + RuntimeProfileRewardCodeRedeemErrorCode::NotFound, + "兑换码不存在", + ) + })?; let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); validate_runtime_profile_redeem_code_usage( @@ -7079,7 +7843,7 @@ fn redeem_profile_reward_code_record( user_used_count, validated_input.redeemed_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(reward_code_redeem_error_from_field_error)?; let usage_id = build_runtime_profile_redeem_code_usage_id( &code, @@ -7132,7 +7896,7 @@ fn redeem_profile_reward_code_record( fn admin_upsert_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( input.admin_user_id, input.code, @@ -7146,7 +7910,7 @@ fn admin_upsert_profile_redeem_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; let existing = ctx @@ -7207,20 +7971,25 @@ fn admin_upsert_profile_redeem_code_record( fn admin_disable_profile_redeem_code_record( ctx: &ReducerContext, input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_redeem_code_admin_disable_input( input.admin_user_id, input.code, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let existing = ctx .db .profile_redeem_code() .code() .find(&validated_input.code) - .ok_or_else(|| "兑换码不存在".to_string())?; + .ok_or_else(|| { + ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::NotFound, + "兑换码不存在", + ) + })?; ctx.db.profile_redeem_code().code().delete(&existing.code); let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { @@ -7242,7 +8011,7 @@ fn admin_disable_profile_redeem_code_record( fn admin_upsert_profile_invite_code_record( ctx: &ReducerContext, input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> Result { +) -> Result { let validated_input = build_runtime_profile_invite_code_admin_upsert_input( input.admin_user_id, input.invite_code, @@ -7251,7 +8020,7 @@ fn admin_upsert_profile_invite_code_record( input.expires_at_micros, input.updated_at_micros, ) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); let user_id = build_admin_profile_invite_code_user_id( &validated_input.admin_user_id, @@ -7267,7 +8036,10 @@ fn admin_upsert_profile_invite_code_record( .find(&validated_input.invite_code) { if existing.user_id != user_id { - return Err("邀请码已被其他用户占用".to_string()); + return Err(ProfileInviteCodeAdminProcedureError::classified( + RuntimeProfileInviteCodeAdminErrorCode::InUse, + "邀请码已被其他用户占用", + )); } ctx.db .profile_invite_code() @@ -7621,22 +8393,18 @@ fn build_profile_recharge_center_snapshot( 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); - } + if product.kind != RuntimeProfileRechargeProductKind::Points { + // 会员商品不在商品配置表里:会员购买 / 升级由 profile_membership_plan 目录表驱动。 + continue; } + 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, + )); } RuntimeProfileRechargeCenterSnapshot { @@ -7644,8 +8412,7 @@ fn build_profile_recharge_center_snapshot( wallet_balance, membership: build_profile_membership_snapshot(ctx, user_id), point_products, - membership_products, - benefits: runtime_profile_membership_benefits(), + membership_plans: membership_plan_records(ctx), latest_order: latest_profile_recharge_order(ctx, user_id) .map(|row| build_profile_recharge_order_snapshot_from_row(&row)), has_points_recharged, @@ -7845,10 +8612,10 @@ fn admin_list_profile_redeem_code_records( Vec, Vec, ), - String, + ProfileRedeemCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(redeem_code_admin_error_from_field_error)?; let mut entries = ctx .db @@ -7873,10 +8640,10 @@ fn admin_list_profile_invite_code_records( Vec, Vec, ), - String, + ProfileInviteCodeAdminProcedureError, > { let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; + .map_err(invite_code_admin_error_from_field_error)?; let mut entries = ctx .db @@ -7905,15 +8672,8 @@ fn upsert_profile_recharge_product_config_record( input.price_cents, input.kind, input.points_amount, - input.bonus_points, - input.duration_days, input.badge_label, input.description, - input.tier, - input.membership_period_points, - input.membership_period_days, - input.membership_queue_limit, - input.membership_discount_bps, input.enabled, input.sort_order, input.updated_at_micros, @@ -7943,11 +8703,13 @@ fn upsert_profile_recharge_product_config_record( 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, + // 已退役列:充点商品固定「¥1 = 10 泥点、无首充赠送」,不再从后台入参接收。 + bonus_points: 0, + duration_days: 0, badge_label: validated_input.badge_label, description: validated_input.description, - tier: validated_input.tier, + // 已退役列:不参与运行态,写固定默认值即可。 + tier: RuntimeProfileMembershipTier::Normal, enabled: validated_input.enabled, sort_order: validated_input.sort_order, created_by: existing @@ -7960,16 +8722,204 @@ fn upsert_profile_recharge_product_config_record( .unwrap_or(updated_at), updated_by: validated_input.admin_user_id, updated_at, - membership_period_points: validated_input.membership_period_points, - membership_period_days: validated_input.membership_period_days, - membership_queue_limit: validated_input.membership_queue_limit, - membership_discount_bps: validated_input.membership_discount_bps, + // 已退役列:不参与运行态,写固定默认值即可。 + membership_period_points: 0, + membership_period_days: 0, + membership_queue_limit: 0, + membership_discount_bps: 0, }); Ok(build_profile_recharge_product_config_snapshot_from_row( &inserted, )) } +fn list_profile_membership_plan_records( + ctx: &ReducerContext, + input: RuntimeProfileMembershipPlanAdminListInput, +) -> Result, String> { + build_runtime_profile_membership_plan_admin_list_input(input.admin_user_id) + .map_err(|error| error.to_string())?; + Ok(membership_plan_records(ctx)) +} + +/// 补差升级报价。非会员、降档、月年互换、当前套餐重复购买都返回可读错误,不做静默兜底。 +/// +/// 全程不刷新账期 / 钱包(不写会员行、不发账本),改用 [`project_profile_membership_cycle_at`] +/// 在内存里把会员行投射到当前时刻再报价,因此与建单、结算读到的账期口径一致, +/// 也不会让「预览报价」提前消耗用户的懒刷新。 +/// +/// 例外:读取档位目录走 [`membership_plan_snapshot`] → [`membership_plan_row`],若目录表尚为空 +/// (存量库增量发布后首次读取)会触发懒播种写库;表非空后保持只读。 +fn build_profile_membership_upgrade_quote_record( + ctx: &ReducerContext, + input: RuntimeProfileMembershipUpgradeQuoteGetInput, +) -> Result { + let Some(row) = ctx + .db + .profile_membership() + .user_id() + .find(&input.user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + else { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::NotMember, + "当前不是会员,请直接选择套餐购买", + )); + }; + // 只读投射账期到当前时刻:不落库、不发账本,避免预览就改会员 / 钱包状态。 + let row = project_profile_membership_cycle_at(ctx, row, ctx.timestamp); + if row.plan == input.target_plan { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); + } + let target = + membership_plan_snapshot(ctx, input.target_plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", input.target_plan.as_str()), + ) + })?; + if !target.enabled { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", input.target_plan.as_str()), + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, + ¤t, + &target, + ctx.timestamp, + )) + .map_err(membership_upgrade_rejection_error)?; + + Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { + current_plan: current.plan, + target_plan: target.plan, + cycle_kind: target.cycle_kind, + cycle_index: quote.cycle_index, + cycle_count: target.cycle_count, + amount_cents: quote.amount_cents, + granted_points_delta: quote.granted_points_delta, + monthly_balance_after: quote.monthly_balance_after, + expires_at_micros: quote.expires_at_micros, + cycle_resets_at_micros: quote.cycle_resets_at_micros, + remaining_full_months: quote.remaining_full_months, + }) +} + +/// 会员升级拒绝 → procedure 错误码。 +/// +/// 目录价格 / 每期泥点倒挂是后端配置错误,映射为 `InvalidPlanConfig`,与客户端入参错误 +/// (`UpgradeRejected`)区分开,便于按错误码定位;人读文案仍带具体 rejection 原因。 +fn membership_upgrade_rejection_error( + rejection: RuntimeProfileMembershipUpgradeRejection, +) -> RuntimeProfileMembershipDomainError { + RuntimeProfileMembershipDomainError::new( + rejection.error_code(), + format!("会员升级被拒绝: {}", rejection.as_str()), + ) +} + +/// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 +fn upsert_profile_membership_plan_record( + ctx: &ReducerContext, + input: RuntimeProfileMembershipPlanAdminUpsertInput, +) -> Result { + let validated = build_runtime_profile_membership_plan_admin_upsert_input( + input.admin_user_id, + input.plan, + input.title, + input.month_price_cents, + input.year_price_cents, + input.period_points, + input.model_access, + input.concurrent_job_limit, + input.enabled, + input.updated_at_micros, + ) + .map_err(|error| error.to_string())?; + ensure_default_profile_membership_plan(ctx); + + // 审计时间以 SpacetimeDB 事务时间为准:入参 `updated_at_micros` 不再被信任, + // 因为 procedure 可被持有 writer 身份者直接调用,客户端时钟可以伪造。 + let updated_at = ctx.timestamp; + let existing = ctx + .db + .profile_membership_plan() + .plan() + .find(&validated.plan); + // 代码内目录是 `rank` 的唯一来源:后台改价、改权益、改上下架都不会动升级比较基准。 + // 缺失档位必须显式报错:回退成 rank 0 会与 Normal 占位同档,产生误导性的校验结果。 + let rank = resolve_runtime_profile_membership_plan_row(validated.plan) + .ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!( + "会员目录缺少档位 {},无法解析升级比较基准", + validated.plan.as_str() + ), + ) + })? + .rank; + + let inserted = ProfileMembershipPlan { + plan: validated.plan, + title: validated.title, + rank, + month_price_cents: validated.month_price_cents, + year_price_cents: validated.year_price_cents, + period_points: validated.period_points, + model_access: validated.model_access, + concurrent_job_limit: validated.concurrent_job_limit, + enabled: validated.enabled, + created_by: existing + .as_ref() + .map(|row| row.created_by.clone()) + .unwrap_or_else(|| validated.admin_user_id.clone()), + created_at: existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(updated_at), + updated_by: validated.admin_user_id, + updated_at, + }; + + // 中文注释:目录必须保持「rank 越高,月价 / 年价 / 每期泥点越高」。 + // 后台单次改价不能制造倒挂,否则升级补差会静默算出 0 元 / 0 泥点。 + let candidate = membership_plan_record_from_row(&inserted); + let catalog_rows = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + module_runtime::validate_runtime_profile_membership_plan_row_against_catalog( + &candidate, + &catalog_rows, + ) + .map_err(|error| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + error.to_string(), + ) + })?; + + if let Some(row) = existing { + ctx.db.profile_membership_plan().plan().delete(&row.plan); + } + let inserted = ctx.db.profile_membership_plan().insert(inserted); + Ok(membership_plan_record_from_row(&inserted)) +} + fn upsert_profile_task_config_record( ctx: &ReducerContext, input: RuntimeProfileTaskConfigAdminUpsertInput, @@ -8451,6 +9401,57 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig }) } +/// 中文注释:会员档位目录以代码内目录为种子真相源,表为空时整表播种一次;后台改价后不再覆盖。 +/// +/// 播种策略(已定):`init` reducer 与读取 helper(`membership_plan_row` / `membership_plan_records`) +/// 共用本函数。`init` 只在数据库首次创建时执行,增量发布不会重跑,因此「表已存在但为空」的存放量库 +/// 仍要靠读取 helper 懒播种。**代价**:报价 / 充值中心 / 后台查询等只读入口在目录为空的首次调用会写库; +/// 表非空后本函数直接返回,后续调用保持只读。若将来改为发布期统一播种,再删除这里的懒播种与注释。 +fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { + // 中文注释:按档位逐行补齐,而不是只在空表时整表播种——增量发布不会重跑 `init`, + // 老库可能只有部分档位;只在空表播种会让缺行的库永远缺下去,目录覆盖校验与报价都会受影响。 + // 已存在的行保持后台改过的价格 / 权益不动,只补目录里没有的档位。 + let existing = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + let missing = module_runtime::missing_runtime_profile_membership_plan_rows(&existing); + + let now = ctx.timestamp; + for row in missing { + ctx.db + .profile_membership_plan() + .insert(ProfileMembershipPlan { + plan: row.plan, + title: row.title, + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + enabled: row.enabled, + 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, + }); + } +} + +/// 模块初始化生命周期:数据库首次创建时把会员档位目录整表播种。 +/// +/// 本 reducer **不覆盖**「目录表已存在但为空」的存放量库:增量发布不会重跑 `init`。这类库由 +/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 按档位逐行补齐兜底,因此报价 / +/// 充值中心 / 后台查询等「只读」入口首次调用仍可能写库(全部档位补齐后幂等无写)。这是当前 +/// 有意保留的播种策略,不是漏改;若改为发布期统一回填,需同步删除懒补齐调用并更新本注释。 +#[spacetimedb::reducer(init)] +pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { + ensure_default_profile_membership_plan(ctx); +} + fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { if ctx.db.profile_recharge_product_config().count() > 0 { migrate_legacy_default_profile_recharge_product_config(ctx); @@ -8460,7 +9461,6 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { 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 @@ -8475,40 +9475,39 @@ fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { duration_days: product.duration_days, badge_label: product.badge_label, description: product.description, - tier: product.tier, + // 已退役列:不参与运行态,写固定默认值即可。 + tier: RuntimeProfileMembershipTier::Normal, 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, - membership_period_points: product.membership_period_points, - membership_period_days: product.membership_period_days, - membership_queue_limit: product.membership_queue_limit, - membership_discount_bps: product.membership_discount_bps, + // 已退役列:不参与运行态,写固定默认值即可。 + membership_period_points: 0, + membership_period_days: 0, + membership_queue_limit: 0, + membership_discount_bps: 0, }); } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// 旧充点商品行的规则迁移结果。新方案只保留六档「¥1 = 10 泥点、无任何首充赠送」。 struct DefaultPointProductMigration { bonus_points: u64, badge_label: &'static str, - description: &'static str, + description: String, enabled: bool, } fn resolve_default_point_product_migration( row: &ProfileRechargeProductConfig, ) -> Option { + // 只迁移系统播种的充点商品;后台改过的行(`updated_by` 已变)一律不动。 if row.kind != RuntimeProfileRechargeProductKind::Points - || row.tier != RuntimeProfileMembershipTier::Normal || row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID - || !row.enabled || row.duration_days != 0 - || row.bonus_points != row.points_amount - || row.badge_label != "首充双倍" - || row.description != format!("首充送{}泥点", row.points_amount) + || row.points_amount == 0 { return None; } @@ -8522,39 +9521,29 @@ fn resolve_default_point_product_migration( return None; } - match row.points_amount { - 60 => Some(DefaultPointProductMigration { - bonus_points: 0, - badge_label: "", - description: "60泥点", - enabled: true, - }), - 180 => Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - 300 => Some(DefaultPointProductMigration { - bonus_points: 150, - badge_label: "首充加赠", - description: "首充加赠150泥点", - enabled: true, - }), - 680 => Some(DefaultPointProductMigration { - bonus_points: 340, - badge_label: "首充加赠", - description: "首充加赠340泥点", - enabled: true, - }), - 1_280 | 3_280 => Some(DefaultPointProductMigration { - bonus_points: row.bonus_points, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - _ => None, + let canonical = DefaultPointProductMigration { + bonus_points: 0, + badge_label: "", + description: expected_title, + enabled: true, + }; + + // 旧播种行:首充赠送(赠送 = 面额)+「首充双倍」标签 +「首充送N泥点」说明。 + let is_legacy_bonus_seed = row.enabled + && row.bonus_points == row.points_amount + && row.badge_label == "首充双倍" + && row.description == format!("首充送{}泥点", row.points_amount); + // ¥128 / ¥328 两档曾是「停用的首充双倍行」:旧迁移把赠送保留成面额,所以两种形状都要认。 + let is_legacy_disabled_seed = !row.enabled + && row.badge_label == "首充双倍" + && (row.bonus_points == 0 || row.bonus_points == row.points_amount); + // 180 / 300 / 680 三档被上一版迁移改成了在售的「首充加赠」行,也要归一。 + let is_legacy_first_charge_bonus_seed = row.enabled && row.badge_label == "首充加赠"; + + if is_legacy_bonus_seed || is_legacy_disabled_seed || is_legacy_first_charge_bonus_seed { + return Some(canonical); } + None } fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { @@ -8569,9 +9558,7 @@ fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) }; row.bonus_points = migration.bonus_points; row.badge_label = migration.badge_label.to_string(); - if !migration.description.is_empty() { - row.description = migration.description.to_string(); - } + row.description = migration.description; row.enabled = migration.enabled; row.updated_at = ctx.timestamp; ctx.db @@ -8637,10 +9624,165 @@ fn resolve_profile_recharge_product_display( product } +/// 会员订单产生的权益变更快照,落到订单行上供人工复核退款时还原变更前后状态。 #[derive(Clone, Debug, PartialEq, Eq)] -struct ProfileMembershipPurchaseResult { +struct ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan_before: Option, + plan_after: Option, + cycle_kind_before: Option, + cycle_kind_after: Option, + period_points_before: Option, + period_points_after: Option, + granted_points_delta: u64, + cycle_index: Option, + expires_at_before_micros: Option, + expires_at_after_micros: Option, + cycle_resets_at_before_micros: Option, + cycle_resets_at_after_micros: Option, + price_breakdown_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipPurchaseOutcome { expires_at: Timestamp, period_points_delta: u64, + change: ProfileMembershipChangeSnapshot, +} + +/// 建单时冻结的会员订单快照:档位、价格与补点在建单那一刻确定。 +/// +/// 支付结算只消费这份快照,不再回查 `profile_membership_plan` 目录,因此「下单后改价 / +/// 改每期泥点 / 下架档位」都不会改变已受理订单的结算口径,也便于后台按订单审计。 +/// 快照写入 `ProfileRechargeOrder` 已有的会员变更快照列,不新增 schema 字段。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + // 末位追加:下单当期的期号与刷新时刻,支付时若账期已推进则拒绝重复补点。 + cycle_index_before: Option, + cycle_resets_at_before_micros: Option, + amount_cents: u64, + price_breakdown_json: String, +} + +/// 会员订单结算输入:档位、每期泥点、补点与变更前后快照。 +/// +/// 有建单冻结快照时直接来自订单行;历史 Pending 订单(建单时还没有冻结快照) +/// 回退到按结算时目录重算,保持旧口径。 +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, + period_points: u64, + grant_points: u64, + plan_before: Option, + cycle_kind_before: Option, + period_points_before: Option, + // 建单冻结的当期身份;历史重算订单为 `None`,此时不做账期推进校验。 + cycle_index_before: Option, + cycle_resets_at_before_micros: Option, + price_breakdown_json: String, +} + +/// 把建单冻结的会员快照写到订单行的会员变更快照列上。 +fn apply_profile_membership_order_snapshot_to_order( + order: &mut ProfileRechargeOrder, + snapshot: &ProfileMembershipOrderSnapshot, +) { + order.membership_change_kind = Some(snapshot.change_kind); + order.membership_plan_before = snapshot.plan_before; + order.membership_plan_after = Some(snapshot.plan); + order.membership_cycle_kind_before = snapshot.cycle_kind_before; + order.membership_cycle_kind_after = Some(snapshot.cycle_kind); + order.membership_period_points_before = snapshot.period_points_before; + order.membership_period_points_after = Some(snapshot.period_points); + order.membership_granted_points_delta = snapshot.grant_points; + order.membership_cycle_index = snapshot.cycle_index_before; + order.membership_cycle_resets_at_before = snapshot + .cycle_resets_at_before_micros + .map(Timestamp::from_micros_since_unix_epoch); + order.membership_price_breakdown_json = Some(snapshot.price_breakdown_json.clone()); +} + +/// 从订单行读取建单时冻结的会员快照;非会员订单或未冻结的历史订单返回 `None`。 +fn frozen_profile_membership_settlement( + order: &ProfileRechargeOrder, +) -> Option { + Some(ProfileMembershipSettlement { + change_kind: order.membership_change_kind?, + plan: order.membership_plan_after?, + cycle_kind: order.membership_cycle_kind_after?, + period_points: order.membership_period_points_after?, + grant_points: order.membership_granted_points_delta, + plan_before: order.membership_plan_before, + cycle_kind_before: order.membership_cycle_kind_before, + period_points_before: order.membership_period_points_before, + cycle_index_before: order.membership_cycle_index, + cycle_resets_at_before_micros: order + .membership_cycle_resets_at_before + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: order.membership_price_breakdown_json.clone()?, + }) +} + +/// 冻结的账期身份是否仍与结算时会员行一致。 +/// +/// 只有升级单会冻结当期身份;冻结值为 `None`(历史无快照的重算订单)时视为一致, +/// 因为这类订单的补点已按结算时周期现算。期号与刷新时刻任一被推进都判为不一致。 +fn frozen_membership_cycle_matches( + settlement: &ProfileMembershipSettlement, + row: &ProfileMembership, +) -> bool { + if let Some(cycle_index_before) = settlement.cycle_index_before { + // `cycle_index == 0` 是存量「水位未初始化」哨兵;只读投影统一 `max(1)` 归一到第 1 期。 + // 结算侧同样归一,否则迁移行会被永久判成「会员状态已变化」而无法结算(评审第 20 条)。 + if cycle_index_before.max(1) != row.cycle_index.max(1) { + return false; + } + } + if let Some(cycle_resets_at_before_micros) = settlement.cycle_resets_at_before_micros { + if Some(cycle_resets_at_before_micros) + != row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()) + { + return false; + } + } + true +} + +/// 订单里的价格变更快照(JSON)。人工复核退款时据此确认「按哪个价、补了多少点」。 +fn membership_price_breakdown_json( + target: &RuntimeProfileMembershipPlanSnapshot, + current: Option<&RuntimeProfileMembershipPlanSnapshot>, + order_amount_cents: u64, +) -> String { + serde_json::to_string(&json!({ + "changeKind": match current { + Some(_) => RuntimeProfileMembershipChangeKind::Upgrade, + None => RuntimeProfileMembershipChangeKind::Purchase, + } + .as_str(), + "targetPlan": target.plan.as_str(), + "targetCycleKind": target.cycle_kind.as_str(), + "targetListPriceCents": target.price_cents, + "targetPeriodPoints": target.period_points, + "currentPlan": current.map(|value| value.plan.as_str()), + "currentCycleKind": current.map(|value| value.cycle_kind.as_str()), + "currentListPriceCents": current.map(|value| value.price_cents), + "currentPeriodPoints": current.map(|value| value.period_points), + "orderAmountCents": order_amount_cents, + })) + .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -8673,202 +9815,216 @@ impl DailyFreePointMutation { } } -struct MembershipCycleInitialization { +/// 会员周期推进结果;`advanced = false` 表示行仍处在本期,无需写账本。 +struct MembershipCycleAdvance { row: ProfileMembership, - granted_points_delta: u64, - cycle_resets_at: Timestamp, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ActiveMembershipPurchaseMode { - Renew, - Upgrade, -} - -fn normalized_membership_period_days(period_days: u32) -> u32 { - if period_days == 0 { - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS - } else { - period_days - } -} - -fn membership_period_micros(period_days: u32) -> i64 { - i64::from(normalized_membership_period_days(period_days)) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) -} - -fn membership_cycle_reset_at(started_at: Timestamp, period_days: u32) -> Timestamp { - Timestamp::from_micros_since_unix_epoch( - started_at - .to_micros_since_unix_epoch() - .saturating_add(membership_period_micros(period_days)), - ) -} - -fn membership_product_period_points(product: &RuntimeProfileRechargeProductSnapshot) -> u64 { - if product.membership_period_points == 0 { - runtime_profile_membership_period_points(product.tier) - } else { - product.membership_period_points - } -} - -fn membership_product_period_days(product: &RuntimeProfileRechargeProductSnapshot) -> u32 { - normalized_membership_period_days(product.membership_period_days) + advanced: bool, + previous_remaining_points: u64, + cycle_started_at_micros: i64, } fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch() } -fn canonical_membership_pricing_tier( - tier: RuntimeProfileMembershipTier, -) -> RuntimeProfileMembershipTier { - match tier { - RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro, - _ => tier, - } -} - -fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool { - matches!( - tier, - RuntimeProfileMembershipTier::Month - | RuntimeProfileMembershipTier::Season - | RuntimeProfileMembershipTier::Year - ) -} - -fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 { - runtime_profile_membership_tier_rank(tier).unwrap_or(0) -} - -fn resolve_active_membership_purchase_mode( - current_tier: RuntimeProfileMembershipTier, - next_tier: RuntimeProfileMembershipTier, -) -> Result { - if current_tier == next_tier { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - let current_rank = active_membership_tier_rank(current_tier); - let next_rank = active_membership_tier_rank(next_tier); - let is_legacy_equivalent_migration = current_rank == next_rank - && is_legacy_membership_tier(current_tier) - && canonical_membership_pricing_tier(current_tier) == next_tier; - - if is_legacy_equivalent_migration { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - if next_rank > current_rank { - return Ok(ActiveMembershipPurchaseMode::Upgrade); - } - - Err("当前会员有效期内不能购买更低档会员".to_string()) -} - -fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 { - target_price_cents.saturating_sub(current_price_cents) -} - -fn membership_product_price_cents_by_tier( +/// 会员目录行读取。`profile_membership_plan` 表是价格与权益的唯一真相源: +/// 代码内种子只在表为空时落一次,之后后台改价不会被覆盖。 +fn membership_plan_row( ctx: &ReducerContext, - tier: RuntimeProfileMembershipTier, -) -> Option { - let canonical_tier = canonical_membership_pricing_tier(tier); - profile_recharge_product_config_rows(ctx, false) - .into_iter() - .find(|row| { - row.kind == RuntimeProfileRechargeProductKind::Membership - && canonical_membership_pricing_tier(row.tier) == canonical_tier - }) - .map(|row| row.price_cents) - .or_else(|| { - runtime_profile_recharge_membership_products() - .into_iter() - .find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier) - .map(|product| product.price_cents) - }) + plan: RuntimeProfileMembershipPlan, +) -> Option { + // 存量库增量发布不会触发 init reducer;首次读取时懒播种(会写库),避免目录为空导致下单 / 报价失败; + // 表非空后本调用保持只读。 + ensure_default_profile_membership_plan(ctx); + ctx.db.profile_membership_plan().plan().find(&plan) } -fn resolve_profile_membership_order_amount_cents( +/// 账号当前有效的并发上限:会员行**当前有效档位** → 目录行 `concurrent_job_limit`。 +/// +/// `profile_membership.plan` 保留最近一次购买的档位(到期后仍供展示「已失效的套餐」), +/// 所以这里必须像 [`build_profile_membership_snapshot_from_row`] 一样先按 `expires_at` 判断有效性: +/// 过期行回落 `Normal`,否则过期 `Max` 会继续拿到 `128`(不限并发)哨兵。 +/// +/// 缺会员行 / 已过期按 `Normal`(非会员);缺目录行**失败关闭**到 `Normal`(=1), +/// `128` 哨兵由 [`is_unlimited_concurrency`] 在调用方解释。 +pub(crate) fn effective_profile_concurrent_job_limit( ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - let Some(row) = ctx + owner_user_id: &str, +) -> u32 { + let plan = ctx .db .profile_membership() .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, created_at)) - else { - return Ok(product.price_cents); - }; - - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - return Ok(product.price_cents); - } - - let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0); - Ok(membership_upgrade_amount_cents( - product.price_cents, - current_price_cents, - )) + .find(&owner_user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + .map(|row| row.plan) + .unwrap_or(RuntimeProfileMembershipPlan::Normal); + membership_plan_row(ctx, plan) + .map(|row| row.concurrent_job_limit) + .unwrap_or(1) } -fn resolve_profile_recharge_order_amount_cents( +fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan) -> u64 { + membership_plan_row(ctx, plan) + .map(|row| row.period_points) + .unwrap_or(0) +} + +/// 目录表行 → 领域目录记录(字段一一对应,仅做类型搬运)。 +fn membership_plan_record_from_row( + row: &ProfileMembershipPlan, +) -> RuntimeProfileMembershipPlanRecord { + RuntimeProfileMembershipPlanRecord { + plan: row.plan, + title: row.title.clone(), + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + enabled: row.enabled, + } +} + +fn membership_plan_snapshot( ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - match product.kind { - RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), - RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at) - } + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> Option { + membership_plan_row(ctx, plan).map(|row| { + build_runtime_profile_membership_plan_snapshot( + &membership_plan_record_from_row(&row), + cycle_kind, + ) + }) +} + +/// 充值中心与后台共用的目录行读取,按 `rank` 升序;表为空时先补种代码内目录。 +fn membership_plan_records(ctx: &ReducerContext) -> Vec { + // 存量库增量发布不会触发 init reducer;首次读取时懒播种(会写库),避免充值中心目录为空; + // 表非空后本调用保持只读。 + ensure_default_profile_membership_plan(ctx); + let mut rows = ctx.db.profile_membership_plan().iter().collect::>(); + rows.sort_by(|left, right| { + left.rank + .cmp(&right.rank) + .then_with(|| left.plan.as_str().cmp(right.plan.as_str())) + }); + rows.into_iter() + .map(|row| RuntimeProfileMembershipPlanRecord { + plan: row.plan, + title: row.title, + rank: row.rank, + month_price_cents: row.month_price_cents, + year_price_cents: row.year_price_cents, + period_points: row.period_points, + model_access: row.model_access, + concurrent_job_limit: row.concurrent_job_limit, + enabled: row.enabled, + }) + .collect() +} + +/// 把会员行推进到 `at` 时刻所在的那一期。 +/// +/// 每一期都从**原始锚点**(`started_at`)按自然月重算,因此 1/31 开通得到 +/// 1/31 → 2/28 → 3/31,不会像按固定天数递推那样漂移成 3/28。 +/// +/// `cycle_index` 不是独立真相源:一个有效期里锚点恒定(新购写 `started_at`、期内升级只改档位 +/// 与补点、过期重购才重置),所以期号完全由「锚点 + 当前时刻」推导。它落库只作为**单调水位**—— +/// 推进只取 `max(已存期号, 推导期号)`,只前进、不后退;时钟回拨时 `at` 早于当前窗口结束时间, +/// 循环不进入、`advanced = false`,调用方据此拒绝写库与补发,避免同一账期被重放。 +fn advance_profile_membership_cycle_to( + row: ProfileMembership, + at: Timestamp, + period_points: u64, +) -> MembershipCycleAdvance { + let mut row = row; + // 锚点:有效期内唯一的固定参考,所有账期窗口都由它重算(见 `membership_cycle_window`)。 + let anchor_micros = row.started_at.to_micros_since_unix_epoch(); + let at_micros = at.to_micros_since_unix_epoch(); + let previous_remaining_points = row.cycle_remaining_points; + // 期数上限:年付最多 12 期,再留 2 期余量容错,避免坏数据把循环拖长。 + let max_index = row.cycle_count.max(1).saturating_add(2); + // 起点取已落库的期号(水位下限),保证只前进、不后退。 + let mut cycle_index = row.cycle_index.max(1); + let mut window = membership_cycle_window(anchor_micros, cycle_index); + // `cycle_resets_at` 缺失 = 账期窗口尚未初始化,需要写库补种并发放本期额度; + // `cycle_index == 0` 只是期号水位没写(新列默认值),不能据此判定越界、重发额度。 + let mut advanced = row.cycle_resets_at.is_none(); + if row.cycle_index == 0 { + // 仅补写期号水位:账期额度只应在真正越过边界时重置。 + row.cycle_index = 1; + } + // 仅当 `at` 已越过当前窗口结束时刻才推进;时钟回拨时条件不成立,整行保持原状。 + while window.1 <= at_micros && cycle_index < max_index { + cycle_index = cycle_index.saturating_add(1); + window = membership_cycle_window(anchor_micros, cycle_index); + advanced = true; + } + if advanced { + // 换期即重置本期额度;上期剩余额度由调用方按 `previous_remaining_points` 回收。 + row.cycle_index = cycle_index; + row.cycle_started_at = Some(Timestamp::from_micros_since_unix_epoch(window.0)); + row.cycle_resets_at = Some(Timestamp::from_micros_since_unix_epoch(window.1)); + row.cycle_granted_points = period_points; + row.cycle_remaining_points = period_points; + row.updated_at = at; + } + MembershipCycleAdvance { + row, + advanced, + previous_remaining_points, + cycle_started_at_micros: window.0, } } -fn apply_active_membership_upgrade_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - period_days: u32, - period_points: u64, - updated_at: Timestamp, -) -> (ProfileMembership, u64) { - let period_points_delta = period_points.saturating_sub(row.cycle_granted_points); - row.tier = tier; - row.cycle_granted_points = row.cycle_granted_points.max(period_points); - row.cycle_remaining_points = row - .cycle_remaining_points - .saturating_add(period_points_delta); - row.cycle_period_days = period_days; - row.updated_at = updated_at; - (row, period_points_delta) +/// 只读地把会员行投射到 `at` 时刻所在的那一期:与 [`refresh_profile_membership_cycle`] 同口径, +/// 但不写库、不发账本。 +/// +/// 建单需要「假如现在刷新会得到什么周期数据」才能算对补差;直接读库会用到已过期的账期 +/// (`cycle_resets_at` 在过去、`cycle_remaining_points` 还是上一期),因此用这个只读投影 +/// 替代写库刷新,保证建单金额与预览、结算三处口径一致。 +fn project_profile_membership_cycle_at( + ctx: &ReducerContext, + row: ProfileMembership, + at: Timestamp, +) -> ProfileMembership { + if !active_membership_row_at(&row, at) { + let mut expired = row; + expired.status = RuntimeProfileMembershipStatus::Normal; + expired.cycle_remaining_points = 0; + return expired; + } + let period_points = membership_plan_period_points(ctx, row.plan); + advance_profile_membership_cycle_to(row, at, period_points).row } -fn apply_active_membership_renew_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - duration_days: u32, - purchased_at: Timestamp, -) -> ProfileMembership { - row.tier = tier; - row.expires_at = Timestamp::from_micros_since_unix_epoch( - row.expires_at - .to_micros_since_unix_epoch() - .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)), - ); - row.updated_at = purchased_at; - row +/// 升级 / 报价共用的纯函数入参:两个套餐快照都来自目录表。 +fn membership_upgrade_quote_input( + row: &ProfileMembership, + current: &RuntimeProfileMembershipPlanSnapshot, + target: &RuntimeProfileMembershipPlanSnapshot, + now: Timestamp, +) -> RuntimeProfileMembershipUpgradeQuoteInput { + RuntimeProfileMembershipUpgradeQuoteInput { + current: current.clone(), + target: target.clone(), + // 期号即账期水位(1-based);年付补差按 `cycle_count - cycle_index` 折算剩余整月。 + cycle_index: row.cycle_index.max(1), + cycle_started_at_micros: row + .cycle_started_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.started_at.to_micros_since_unix_epoch()), + cycle_resets_at_micros: row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()) + .unwrap_or_else(|| row.expires_at.to_micros_since_unix_epoch()), + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + cycle_remaining_points: row.cycle_remaining_points, + now_micros: now.to_micros_since_unix_epoch(), + } } fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { @@ -8876,44 +10032,153 @@ fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { ctx.db.profile_membership().insert(row); } -fn initialize_missing_profile_membership_cycle( - mut row: ProfileMembership, - now: Timestamp, -) -> Option { - if row.cycle_resets_at.is_some() { - return None; +/// 会员商品在订单里的商品快照。会员商品没有 `profile_recharge_product_config` 行: +/// `product_id` 就是「档位 + 周期」,标题与价格来自 `profile_membership_plan` 目录表。 +fn build_membership_order_product( + row: &ProfileMembershipPlan, + plan: RuntimeProfileMembershipPlan, + cycle_kind: RuntimeProfileMembershipCycleKind, +) -> RuntimeProfileRechargeProductSnapshot { + let snapshot = build_runtime_profile_membership_plan_snapshot( + &membership_plan_record_from_row(row), + cycle_kind, + ); + let cycle_label = match cycle_kind { + RuntimeProfileMembershipCycleKind::Monthly => "月付", + RuntimeProfileMembershipCycleKind::Yearly => "年付", + }; + RuntimeProfileRechargeProductSnapshot { + product_id: runtime_profile_membership_product_id(plan, cycle_kind), + title: format!("{}·{}", snapshot.title, cycle_label), + price_cents: snapshot.price_cents, + kind: RuntimeProfileRechargeProductKind::Membership, + points_amount: 0, + bonus_points: 0, + duration_days: 0, + badge_label: String::new(), + description: snapshot.title, + } +} + +/// 下单时可购买的商品:会员商品要求档位在售,充点商品要求行已启用。 +fn resolve_enabled_profile_recharge_order_product( + ctx: &ReducerContext, + product_id: &str, +) -> Option { + if let Some((plan, cycle_kind)) = parse_runtime_profile_membership_product_id(product_id) { + let row = membership_plan_row(ctx, plan)?; + if !(row.enabled && plan.is_purchasable()) { + return None; + } + return Some(build_membership_order_product(&row, plan, cycle_kind)); + } + enabled_profile_recharge_product_by_id(ctx, product_id) +} + +/// 建单时冻结会员订单快照:档位、价格、补点都在此确定,之后支付只认这份快照。 +/// +/// 会员订单应付金额:首次购买按目录价;有效期内升级按补差报价重算。 +/// 计算补差前先用 [`project_profile_membership_cycle_at`] 只读地把账期投射到建单时刻, +/// 不用过期账期,因此建单金额与「预览报价」「支付结算」三处口径一致。 +/// +/// 例外:读取档位目录同样走懒播种,目录表为空的首次调用会写库(见 +/// [`ensure_default_profile_membership_plan`] 的播种策略说明)。 +fn build_profile_membership_order_snapshot( + ctx: &ReducerContext, + user_id: &str, + product_id: &str, + created_at: Timestamp, +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; + if !target.enabled { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanNotPurchasable, + format!("会员档位 {} 当前不可购买", plan.as_str()), + )); } - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at); - let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days); - let now_micros = now.to_micros_since_unix_epoch(); - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); + // 只读地把账期投射到建单时刻(不写库),避免用到过期账期导致金额与预览 / 结算不一致。 + let active_row = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, created_at)) + .map(|row| project_profile_membership_cycle_at(ctx, row, created_at)); + + let Some(row) = active_row else { + return Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + cycle_index_before: None, + cycle_resets_at_before_micros: None, + amount_cents: target.price_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + target.price_cents, + ), + }); + }; + if row.plan == plan { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); } - - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points); - row.cycle_started_at = Some(cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - - Some(MembershipCycleInitialization { - row, - granted_points_delta, - cycle_resets_at: next_reset_at, + if row.cycle_kind != cycle_kind { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + &row, ¤t, &target, created_at, + )) + .map_err(membership_upgrade_rejection_error)?; + Ok(ProfileMembershipOrderSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + cycle_index_before: Some(row.cycle_index), + cycle_resets_at_before_micros: row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), + amount_cents: quote.amount_cents, + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + quote.amount_cents, + ), }) } @@ -9181,8 +10446,12 @@ fn refresh_profile_wallet_expiring_points( refresh_profile_membership_cycle(ctx, user_id, membership_now); } +/// 会员周期刷新。 +/// +/// - 已到期:清月度余额并置为非会员(永久泥点不受影响)。 +/// - 未到期:按北京时间自然月从**原始锚点**推进到当前期,清上期余额再发新额度。 fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let Some(mut row) = ctx + let Some(row) = ctx .db .profile_membership() .user_id() @@ -9196,11 +10465,12 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti if expires_at_micros <= now_micros { let expired_points = row.cycle_remaining_points; if row.status != RuntimeProfileMembershipStatus::Normal || expired_points > 0 { - row.status = RuntimeProfileMembershipStatus::Normal; - row.cycle_remaining_points = 0; - row.updated_at = now; + let mut expired_row = row; + expired_row.status = RuntimeProfileMembershipStatus::Normal; + expired_row.cycle_remaining_points = 0; + expired_row.updated_at = now; let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); - upsert_profile_membership_row(ctx, row); + upsert_profile_membership_row(ctx, expired_row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9215,72 +10485,20 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti return; } - if row.cycle_resets_at.is_none() { - let Some(initialization) = initialize_missing_profile_membership_cycle(row, now) else { - return; - }; - let ledger_id = format!( - "membership-period-initialize:{user_id}:{}", - initialization.cycle_resets_at.to_micros_since_unix_epoch() - ); - let granted_points_delta = initialization.granted_points_delta; - row = initialization.row; - let cycle_resets_at = row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - now, - membership_cycle_metadata("initialize", 0, granted_points_delta, cycle_resets_at), - ); - let Some(refreshed_row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - row = refreshed_row; - } - - let Some(mut next_reset_at) = row.cycle_resets_at else { - return; - }; - if next_reset_at.to_micros_since_unix_epoch() > now_micros { + let period_points = membership_plan_period_points(ctx, row.plan); + let advance = advance_profile_membership_cycle_to(row, now, period_points); + // 未换期(含时钟回拨)直接返回:不写库、不补发,靠已落库的窗口水位挡住账期回退。 + if !advance.advanced { return; } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut next_cycle_started_at = next_reset_at; - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - next_cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let expired_points = row.cycle_remaining_points; - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - row.cycle_started_at = Some(next_cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch(); - let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}"); - upsert_profile_membership_row(ctx, row); + let expired_points = advance.previous_remaining_points; + let granted_points = advance.row.cycle_granted_points; + let next_reset_at = advance.row.cycle_resets_at; + let ledger_id = format!( + "membership-period-reset:{user_id}:{}", + advance.cycle_started_at_micros + ); + upsert_profile_membership_row(ctx, advance.row); update_profile_wallet_balance_for_expiring_points( ctx, user_id, @@ -9289,27 +10507,48 @@ fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Ti RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, &ledger_id, now, - membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)), + membership_cycle_metadata("reset", expired_points, granted_points, next_reset_at), ); } +/// 会写库的会员快照:先刷新钱包 / 账期(可能换期、补发每日免费额度),再按库内投影读取。 fn build_profile_membership_snapshot( ctx: &ReducerContext, user_id: &str, ) -> RuntimeProfileMembershipSnapshot { refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); + build_profile_membership_snapshot_readonly(ctx, user_id) +} + +/// 纯读会员快照:用只读账期投影替代写库刷新,不落任何表,供后台详情等只读入口使用。 +/// +/// 与写库刷新后的对外语义一致:未过期按当前账期投影,已过期按非会员返回;区别只是不触发换期与补发。 +fn build_profile_membership_snapshot_readonly( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileMembershipSnapshot { let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); let membership = ctx .db .profile_membership() .user_id() - .find(&user_id.to_string()); + .find(&user_id.to_string()) + .map(|row| project_profile_membership_cycle_at(ctx, row, ctx.timestamp)); + build_profile_membership_snapshot_from_row(user_id, membership, now_micros) +} + +/// 把会员行(`None` 表示从未开通)映射为对外快照,是写库与纯读两条路径共用的投影。 +fn build_profile_membership_snapshot_from_row( + user_id: &str, + membership: Option, + now_micros: i64, +) -> RuntimeProfileMembershipSnapshot { match membership { Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => { RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: row.status, - tier: row.tier, + plan: row.plan, started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), @@ -9321,13 +10560,16 @@ fn build_profile_membership_snapshot( .map(|value| value.to_micros_since_unix_epoch()), cycle_granted_points: row.cycle_granted_points, cycle_remaining_points: row.cycle_remaining_points, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, } } + // 中文注释:到期后对外只暴露“非会员”,但保留原周期信息供展示“已失效的套餐”。 Some(row) => RuntimeProfileMembershipSnapshot { user_id: row.user_id, status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), @@ -9339,12 +10581,14 @@ fn build_profile_membership_snapshot( .map(|value| value.to_micros_since_unix_epoch()), cycle_granted_points: row.cycle_granted_points, cycle_remaining_points: 0, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), + cycle_index: row.cycle_index.max(1), + cycle_count: row.cycle_count.max(1), + cycle_kind: row.cycle_kind, }, None => RuntimeProfileMembershipSnapshot { user_id: user_id.to_string(), status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, + plan: RuntimeProfileMembershipPlan::Normal, started_at_micros: None, expires_at_micros: None, updated_at_micros: None, @@ -9352,116 +10596,360 @@ fn build_profile_membership_snapshot( cycle_resets_at_micros: None, cycle_granted_points: 0, cycle_remaining_points: 0, - cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, + cycle_index: 1, + cycle_count: 1, + cycle_kind: RuntimeProfileMembershipCycleKind::Monthly, }, } } +/// 会员购买 / 补差升级写路径。 +/// +/// - 结算口径以订单建单时冻结的快照为准:档位、每期泥点、补点与价格都不再回查目录, +/// 因此下单后改价 / 改每期泥点 / 下架档位都不会影响已受理订单;历史 Pending 订单回退到按目录重算。 +/// - 有效期内:只允许「同周期升更高档位」,按快照补点,刷新日与到期日不变。 +/// - 无有效会员(从未开通或已到期):按快照重新起算,锚点就是本次支付时刻; +/// 但补差价订单结算时会员已到期,必须先满足 [`check_expired_membership_upgrade_settlement`], +/// 否则拒绝——补差价只覆盖当期剩余权益的差额,不覆盖整期新购价。 +/// +/// 幂等锚点是**订单维度**的账本 id,重复支付回调天然幂等。 fn apply_profile_membership_purchase( ctx: &ReducerContext, user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, + order: &ProfileRechargeOrder, purchased_at: Timestamp, -) -> Result { +) -> Result { refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); - let tier = product.tier; - let duration_days = product.duration_days; - let period_days = membership_product_period_days(product); - let period_points = membership_product_period_points(product); - let current = ctx + + let active_row = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, purchased_at)); + + let settlement = match frozen_profile_membership_settlement(order) { + Some(settlement) => settlement, + None => build_recomputed_profile_membership_settlement( + ctx, + &active_row, + &order.product_id, + order.amount_cents, + purchased_at, + )?, + }; + + // 中文注释:冻结的新购单在下单时用户无有效会员;若支付前用户已通过其他订单获得有效会员, + // 继续走新购会按 user_id 删除重插,覆盖掉更新 / 更长的会员(降档或丢失已付时长), + // 因此拒绝并要求重新下单。历史重算路径只会在 active_row 为空时产出 Purchase,不受影响。 + if settlement.change_kind == RuntimeProfileMembershipChangeKind::Purchase + && active_row.is_some() + { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); + } + + if settlement.change_kind == RuntimeProfileMembershipChangeKind::Upgrade { + if let Some(row) = active_row { + // 中文注释:结算前比对订单冻结的变更前状态。若用户已先付了更高档位的升级单、 + // 或到期后按不同周期重新购买,旧升级单直接落库会把档位 / 周期覆盖回去, + // 只剩原到期日,产生降档或「年付 + 一个月到期」的矛盾行,因此拒绝并要求重新下单。 + if settlement.plan_before != Some(row.plan) + || settlement.cycle_kind_before != Some(row.cycle_kind) + { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); + } + // 中文注释:冻结快照记录了下单当期的期号与刷新时刻。若支付前账期已推进, + // 按旧周期剩余比例算出的补点会叠加到已重置的新周期上(多发泥点),因此拒绝。 + if !frozen_membership_cycle_matches(&settlement, &row) { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::StateChanged, + "会员状态已变化,请重新下单", + ) + .into()); + } + return apply_profile_membership_upgrade( + ctx, + user_id, + row, + &order.order_id, + &settlement, + purchased_at, + ) + .map_err(ProfileMembershipProcedureError::from); + } + // 中文注释:会员在支付前已到期,冻结的补差价只覆盖当期剩余权益的差额, + // 不再覆盖整期新购价;只有订单金额达到目标周期当前目录价才按新购结算, + // 否则拒绝,避免用户用零头补差价换到一个完整周期。 + let target = membership_plan_snapshot(ctx, settlement.plan, settlement.cycle_kind) + .ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", settlement.plan.as_str()), + ) + })?; + check_expired_membership_upgrade_settlement(order.amount_cents, target.price_cents) + .map_err(|_| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::MembershipExpired, + "会员已到期,请按新购价格重新下单", + ) + })?; + } + + apply_profile_membership_fresh_purchase( + ctx, + user_id, + &order.order_id, + &settlement, + purchased_at, + ) + .map_err(ProfileMembershipProcedureError::from) +} + +/// 历史 Pending 订单(建单时还没有冻结快照)的结算口径:按结算时的目录与会员状态重算。 +fn build_recomputed_profile_membership_settlement( + ctx: &ReducerContext, + active_row: &Option, + product_id: &str, + order_amount_cents: u64, + purchased_at: Timestamp, +) -> Result { + let (plan, cycle_kind) = + parse_runtime_profile_membership_product_id(product_id).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::ProductIdUnparseable, + format!("会员商品 product_id 无法解析: {product_id}"), + ) + })?; + let target = membership_plan_snapshot(ctx, plan, cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", plan.as_str()), + ) + })?; + let Some(row) = active_row else { + return Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: target.period_points, + plan_before: None, + cycle_kind_before: None, + period_points_before: None, + cycle_index_before: None, + cycle_resets_at_before_micros: None, + price_breakdown_json: membership_price_breakdown_json( + &target, + None, + order_amount_cents, + ), + }); + }; + if row.plan == plan { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::AlreadyActive, + "当前套餐已生效,无需重复购买", + )); + } + if row.cycle_kind != cycle_kind { + return Err(RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::CycleKindLocked, + "当前会员有效期内不支持月付 / 年付转换,到期后可重新选择", + )); + } + let current = membership_plan_snapshot(ctx, row.plan, row.cycle_kind).ok_or_else(|| { + RuntimeProfileMembershipDomainError::new( + RuntimeProfileMembershipErrorCode::PlanCatalogMissing, + format!("会员目录缺少档位 {}", row.plan.as_str()), + ) + })?; + let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( + row, + ¤t, + &target, + purchased_at, + )) + .map_err(membership_upgrade_rejection_error)?; + Ok(ProfileMembershipSettlement { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan, + cycle_kind, + period_points: target.period_points, + grant_points: quote.granted_points_delta, + plan_before: Some(current.plan), + cycle_kind_before: Some(current.cycle_kind), + period_points_before: Some(current.period_points), + cycle_index_before: None, + cycle_resets_at_before_micros: None, + price_breakdown_json: membership_price_breakdown_json( + &target, + Some(¤t), + order_amount_cents, + ), + }) +} + +/// 有效期内补差升级:按冻结快照换档并追加本期补点,刷新日与到期日不变。 +fn apply_profile_membership_upgrade( + ctx: &ReducerContext, + user_id: &str, + row: ProfileMembership, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; + let expires_at_before_micros = row.expires_at.to_micros_since_unix_epoch(); + let cycle_resets_at_before_micros = row + .cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()); + let mut next_row = row; + // 已用点不返还、永久泥点不动:只把档位换成快照档,并追加本期补点。 + next_row.plan = plan; + next_row.cycle_kind = cycle_kind; + next_row.cycle_count = cycle_kind.cycle_count(); + next_row.cycle_granted_points = next_row.cycle_granted_points.max(settlement.period_points); + next_row.cycle_remaining_points = next_row + .cycle_remaining_points + .saturating_add(settlement.grant_points); + next_row.updated_at = purchased_at; + let expires_at = next_row.expires_at; + let cycle_resets_at = next_row.cycle_resets_at; + let cycle_index = next_row.cycle_index.max(1); + upsert_profile_membership_row(ctx, next_row); + + let ledger_id = format!("membership-upgrade-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + settlement.grant_points, + RuntimeProfileWalletLedgerSourceType::MembershipUpgradeGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata("upgrade", 0, settlement.grant_points, cycle_resets_at), + ); + + Ok(ProfileMembershipPurchaseOutcome { + expires_at, + period_points_delta: settlement.grant_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Upgrade, + plan_before: settlement.plan_before, + plan_after: Some(plan), + cycle_kind_before: settlement.cycle_kind_before, + cycle_kind_after: Some(cycle_kind), + period_points_before: settlement.period_points_before, + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.grant_points, + cycle_index: Some(cycle_index), + expires_at_before_micros: Some(expires_at_before_micros), + expires_at_after_micros: Some(expires_at.to_micros_since_unix_epoch()), + cycle_resets_at_before_micros, + cycle_resets_at_after_micros: cycle_resets_at + .map(|value| value.to_micros_since_unix_epoch()), + price_breakdown_json: Some(settlement.price_breakdown_json.clone()), + }, + }) +} + +/// 无有效会员时按冻结快照全新起算:到期未续的旧行会被整行覆盖(月度余额清零,永久泥点不受影响)。 +fn apply_profile_membership_fresh_purchase( + ctx: &ReducerContext, + user_id: &str, + order_id: &str, + settlement: &ProfileMembershipSettlement, + purchased_at: Timestamp, +) -> Result { + let plan = settlement.plan; + let cycle_kind = settlement.cycle_kind; + let expired_existing = ctx .db .profile_membership() .user_id() .find(&user_id.to_string()); - let purchased_at_micros = purchased_at.to_micros_since_unix_epoch(); - let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS); - - let (next_row, period_points_delta) = match current { - Some(row) if active_membership_row_at(&row, purchased_at) => { - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - ( - apply_active_membership_renew_row(row, tier, duration_days, purchased_at), - 0, - ) - } else { - apply_active_membership_upgrade_row( - row, - tier, - period_days, - period_points, - purchased_at, - ) - } - } - Some(row) => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - None => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } + let anchor_micros = purchased_at.to_micros_since_unix_epoch(); + let (cycle_started_at_micros, cycle_resets_at_micros) = + membership_cycle_window(anchor_micros, 1); + let expires_at_micros = membership_expires_at(anchor_micros, cycle_kind); + let expires_at = Timestamp::from_micros_since_unix_epoch(expires_at_micros); + let cycle_resets_at = Timestamp::from_micros_since_unix_epoch(cycle_resets_at_micros); + let next_row = ProfileMembership { + user_id: user_id.to_string(), + status: RuntimeProfileMembershipStatus::Active, + // 已退役列:不参与运行态,写固定默认值即可。 + tier: RuntimeProfileMembershipTier::Normal, + started_at: purchased_at, + expires_at, + updated_at: purchased_at, + cycle_started_at: Some(Timestamp::from_micros_since_unix_epoch( + cycle_started_at_micros, + )), + cycle_resets_at: Some(cycle_resets_at), + cycle_granted_points: settlement.period_points, + cycle_remaining_points: settlement.period_points, + // 已退役列:不参与运行态,写固定默认值即可。 + cycle_period_days: 30, + plan, + cycle_index: 1, + cycle_count: cycle_kind.cycle_count(), + cycle_kind, }; - - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at.clone(); upsert_profile_membership_row(ctx, next_row); - if period_points_delta > 0 { - let ledger_id = format!( - "membership-period-grant:{user_id}:{}:{}", - purchased_at_micros, product.product_id - ); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, + let ledger_id = format!("membership-period-grant:{user_id}:{order_id}"); + update_profile_wallet_balance_for_expiring_points( + ctx, + user_id, + 0, + settlement.period_points, + RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, + &ledger_id, + purchased_at, + membership_cycle_metadata( + "purchase", 0, - period_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - purchased_at, - membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at), - ); - } + settlement.period_points, + Some(cycle_resets_at), + ), + ); - Ok(ProfileMembershipPurchaseResult { + Ok(ProfileMembershipPurchaseOutcome { expires_at, - period_points_delta, + period_points_delta: settlement.period_points, + change: ProfileMembershipChangeSnapshot { + change_kind: RuntimeProfileMembershipChangeKind::Purchase, + plan_before: expired_existing.as_ref().map(|row| row.plan), + plan_after: Some(plan), + cycle_kind_before: expired_existing.as_ref().map(|row| row.cycle_kind), + cycle_kind_after: Some(cycle_kind), + period_points_before: expired_existing + .as_ref() + .map(|row| row.cycle_granted_points), + period_points_after: Some(settlement.period_points), + granted_points_delta: settlement.period_points, + cycle_index: Some(1), + expires_at_before_micros: expired_existing + .as_ref() + .map(|row| row.expires_at.to_micros_since_unix_epoch()), + expires_at_after_micros: Some(expires_at_micros), + cycle_resets_at_before_micros: expired_existing + .as_ref() + .and_then(|row| row.cycle_resets_at) + .map(|value| value.to_micros_since_unix_epoch()), + cycle_resets_at_after_micros: Some(cycle_resets_at_micros), + price_breakdown_json: Some(settlement.price_breakdown_json.clone()), + }, }) } @@ -10854,7 +12342,7 @@ fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_i fn resolve_profile_redeem_code_allowed_user_ids( ctx: &ReducerContext, input: &RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result, String> { +) -> Result, ProfileRedeemCodeAdminProcedureError> { if input.mode != RuntimeProfileRedeemCodeMode::Private { return Ok(Vec::new()); } @@ -10875,7 +12363,10 @@ fn resolve_profile_redeem_code_allowed_user_ids( allowed_user_ids.dedup(); if allowed_user_ids.is_empty() { - return Err("私有兑换码必须指定可兑换用户".to_string()); + return Err(ProfileRedeemCodeAdminProcedureError::classified( + RuntimeProfileRedeemCodeAdminErrorCode::AllowedUsersRequired, + "私有兑换码必须指定可兑换用户", + )); } Ok(allowed_user_ids) @@ -11000,11 +12491,6 @@ fn build_profile_recharge_product_config_snapshot_from_row( duration_days: row.duration_days, badge_label: row.badge_label.clone(), description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, enabled: row.enabled, sort_order: row.sort_order, created_by: row.created_by.clone(), @@ -11027,11 +12513,6 @@ fn build_profile_recharge_product_snapshot_from_config_row( duration_days: row.duration_days, badge_label: row.badge_label.clone(), description: row.description.clone(), - tier: row.tier, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, } } diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs deleted file mode 100644 index 762ae0a5c..000000000 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ /dev/null @@ -1,10139 +0,0 @@ -use crate::*; -use std::collections::{HashMap, HashSet, VecDeque}; - -const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; -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"; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT: u32 = 20; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX: u32 = 50; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT: u32 = 50; -const PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX: u32 = 200; -const ASSET_OPERATION_CONSUME_LEDGER_PREFIX: &str = "asset_operation_consume:"; -const ASSET_OPERATION_REFUND_LEDGER_PREFIX: &str = "asset_operation_refund:"; - -#[spacetimedb::table(accessor = profile_dashboard_state)] -pub struct ProfileDashboardState { - #[primary_key] - pub(crate) user_id: String, - pub(crate) wallet_balance: u64, - pub(crate) total_play_time_ms: u64, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_daily_free_points)] -#[derive(Clone)] -pub struct ProfileDailyFreePoints { - #[primary_key] - pub(crate) user_id: String, - pub(crate) day_key: i64, - pub(crate) granted_points: u64, - pub(crate) remaining_points: u64, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_wallet_ledger, - index(accessor = by_profile_wallet_ledger_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_wallet_ledger_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -pub struct ProfileWalletLedger { - #[primary_key] - pub(crate) wallet_ledger_id: String, - pub(crate) user_id: String, - pub(crate) amount_delta: i64, - pub(crate) balance_after: u64, - pub(crate) source_type: RuntimeProfileWalletLedgerSourceType, - pub(crate) created_at: Timestamp, - #[default(None::)] - pub(crate) metadata_json: Option, -} - -#[spacetimedb::table(accessor = asset_operation_wallet_settlement)] -#[derive(Clone)] -pub struct AssetOperationWalletSettlement { - #[primary_key] - pub(crate) consume_ledger_id: String, - pub(crate) refund_ledger_id: String, - pub(crate) user_id: String, - pub(crate) amount: u64, - pub(crate) settled_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_wallet_config)] -#[derive(Clone)] -pub struct ProfileWalletConfig { - #[primary_key] - pub(crate) config_id: String, - pub(crate) initial_mud_points: u64, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by: String, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = tracking_event, - index(accessor = by_tracking_event_event_key, btree(columns = [event_key])), - index( - accessor = by_tracking_event_scope, - btree(columns = [scope_kind, scope_id]) - ), - index( - accessor = by_tracking_event_user, - btree(columns = [user_id, occurred_at]) - ) -)] -pub struct TrackingEvent { - #[primary_key] - pub(crate) event_id: String, - pub(crate) event_key: String, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) scope_id: String, - pub(crate) day_key: i64, - pub(crate) user_id: Option, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) module_key: Option, - pub(crate) metadata_json: String, - pub(crate) occurred_at: Timestamp, -} - -#[spacetimedb::table( - accessor = tracking_daily_stat, - index( - accessor = by_tracking_daily_stat_event_day, - btree(columns = [event_key, day_key]) - ), - index( - accessor = by_tracking_daily_stat_scope_day, - btree(columns = [scope_kind, scope_id, day_key]) - ) -)] -pub struct TrackingDailyStat { - #[primary_key] - pub(crate) stat_id: String, - pub(crate) event_key: String, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) scope_id: String, - pub(crate) day_key: i64, - pub(crate) count: u32, - pub(crate) first_occurred_at: Timestamp, - pub(crate) last_occurred_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_task_config)] -pub struct ProfileTaskConfig { - #[primary_key] - pub(crate) task_id: String, - pub(crate) title: String, - pub(crate) description: String, - pub(crate) event_key: String, - pub(crate) cycle: RuntimeProfileTaskCycle, - pub(crate) scope_kind: RuntimeTrackingScopeKind, - pub(crate) threshold: u32, - pub(crate) reward_points: u64, - 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_task_progress, - index(accessor = by_profile_task_progress_user, btree(columns = [user_id])), - index( - accessor = by_profile_task_progress_user_task, - btree(columns = [user_id, task_id]) - ) -)] -pub struct ProfileTaskProgress { - #[primary_key] - pub(crate) progress_id: String, - pub(crate) user_id: String, - pub(crate) task_id: String, - pub(crate) day_key: i64, - pub(crate) progress_count: u32, - pub(crate) threshold: u32, - pub(crate) status: RuntimeProfileTaskStatus, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_task_reward_claim, - index(accessor = by_profile_task_claim_user, btree(columns = [user_id])), - index( - accessor = by_profile_task_claim_user_task, - btree(columns = [user_id, task_id]) - ) -)] -pub struct ProfileTaskRewardClaim { - #[primary_key] - pub(crate) claim_id: String, - pub(crate) user_id: String, - pub(crate) task_id: String, - pub(crate) day_key: i64, - pub(crate) reward_points: u64, - pub(crate) wallet_ledger_id: String, - pub(crate) claimed_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_redeem_code)] -pub struct ProfileRedeemCode { - #[primary_key] - pub(crate) code: String, - pub(crate) mode: RuntimeProfileRedeemCodeMode, - pub(crate) reward_points: u64, - pub(crate) max_uses: u32, - pub(crate) global_used_count: u32, - pub(crate) enabled: bool, - pub(crate) allowed_user_ids: Vec, - pub(crate) created_by: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[spacetimedb::table( - accessor = profile_redeem_code_usage, - index(accessor = by_profile_redeem_code_usage_code, btree(columns = [code])), - index(accessor = by_profile_redeem_code_usage_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_redeem_code_usage_code_user_id, - btree(columns = [code, user_id]) - ) -)] -pub struct ProfileRedeemCodeUsage { - #[primary_key] - pub(crate) usage_id: String, - pub(crate) code: String, - pub(crate) user_id: String, - pub(crate) amount_granted: u64, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_code_operation, - index( - accessor = by_profile_code_operation_code_kind, - btree(columns = [code_kind]) - ), - index( - accessor = by_profile_code_operation_kind_code, - btree(columns = [code_kind, code]) - ) -)] -pub struct ProfileCodeOperation { - #[primary_key] - pub(crate) operation_id: String, - pub(crate) code_kind: String, - pub(crate) code: String, - pub(crate) action: String, - pub(crate) operator_user_id: String, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_invite_code)] -pub struct ProfileInviteCode { - #[primary_key] - pub(crate) user_id: String, - #[unique] - pub(crate) invite_code: String, - pub(crate) metadata_json: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) starts_at: Option, - #[default(None::)] - pub(crate) expires_at: Option, -} - -#[spacetimedb::table( - accessor = profile_referral_relation, - index(accessor = by_profile_referral_inviter_user_id, btree(columns = [inviter_user_id])), - index( - accessor = by_profile_referral_inviter_bound_at, - btree(columns = [inviter_user_id, bound_at]) - ) -)] -pub struct ProfileReferralRelation { - #[primary_key] - pub(crate) invitee_user_id: String, - pub(crate) inviter_user_id: String, - pub(crate) invite_code: String, - pub(crate) inviter_reward_granted: bool, - pub(crate) invitee_reward_granted: bool, - pub(crate) bound_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_played_world, - index(accessor = by_profile_played_world_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_played_world_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_played_world_user_last_played_at, - btree(columns = [user_id, last_played_at]) - ) -)] -pub struct ProfilePlayedWorld { - #[primary_key] - pub(crate) played_world_id: String, - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_title: String, - pub(crate) world_subtitle: String, - pub(crate) first_played_at: Timestamp, - pub(crate) last_played_at: Timestamp, - pub(crate) last_observed_play_time_ms: u64, -} - -#[spacetimedb::table( - accessor = public_work_play_daily_stat, - index( - accessor = by_public_work_play_daily_stat_work_day, - btree(columns = [source_type, profile_id, played_day]) - ) -)] -pub struct PublicWorkPlayDailyStat { - #[primary_key] - pub(crate) stat_id: String, - // 中文注释:source_type 区分 custom-world / puzzle / big-fish,避免不同玩法 profile_id 撞桶。 - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - // 中文注释:UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。 - pub(crate) played_day: i64, - pub(crate) play_count: u32, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = public_work_like, - index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])), - index(accessor = by_public_work_like_user, btree(columns = [user_id])) -)] -pub struct PublicWorkLike { - #[primary_key] - pub(crate) like_id: String, - // 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。 - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) user_id: String, - pub(crate) liked_at: Timestamp, -} - -pub(crate) struct ProfilePlayedWorkUpsertInput { - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_title: String, - pub(crate) world_subtitle: String, - pub(crate) played_at_micros: i64, -} - -pub(crate) struct PublicWorkPlayRecordInput { - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) played_at_micros: i64, -} - -pub(crate) struct PublicWorkLikeRecordInput { - pub(crate) source_type: String, - pub(crate) owner_user_id: String, - pub(crate) profile_id: String, - pub(crate) user_id: String, - pub(crate) liked_at_micros: i64, -} - -pub(crate) struct ProfileSaveArchiveUpsertInput { - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - pub(crate) saved_at_micros: i64, -} - -#[spacetimedb::table(accessor = profile_membership)] -pub struct ProfileMembership { - #[primary_key] - pub(crate) user_id: String, - pub(crate) status: RuntimeProfileMembershipStatus, - pub(crate) tier: RuntimeProfileMembershipTier, - pub(crate) started_at: Timestamp, - pub(crate) expires_at: Timestamp, - pub(crate) updated_at: Timestamp, - #[default(None::)] - pub(crate) cycle_started_at: Option, - #[default(None::)] - pub(crate) cycle_resets_at: Option, - #[default(0u64)] - pub(crate) cycle_granted_points: u64, - #[default(0u64)] - pub(crate) cycle_remaining_points: u64, - #[default(30u32)] - pub(crate) cycle_period_days: u32, -} - -#[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, - #[default(0u64)] - pub(crate) membership_period_points: u64, - #[default(0u32)] - pub(crate) membership_period_days: u32, - #[default(0u32)] - pub(crate) membership_queue_limit: u32, - #[default(0u32)] - pub(crate) membership_discount_bps: u32, -} - -#[spacetimedb::table( - accessor = profile_recharge_order, - index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_recharge_order_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrder { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) product_id: String, - pub(crate) product_title: String, - pub(crate) kind: RuntimeProfileRechargeProductKind, - pub(crate) amount_cents: u64, - pub(crate) status: RuntimeProfileRechargeOrderStatus, - pub(crate) payment_channel: String, - #[default(None::)] - pub(crate) paid_at: Option, - #[default(None::)] - pub(crate) provider_transaction_id: Option, - pub(crate) created_at: Timestamp, - pub(crate) points_delta: i64, - pub(crate) membership_expires_at: Option, - #[default(None::)] - pub(crate) expired_at: Option, - #[default(None::)] - pub(crate) expiration_checked_at: Option, - #[default(None::)] - pub(crate) expiration_provider_state: Option, - #[default(None::)] - pub(crate) expiration_last_error: Option, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund, - index(accessor = by_profile_recharge_refund_order_id, btree(columns = [order_id])), - index( - accessor = by_profile_recharge_refund_status_updated_at, - btree(columns = [provider_status, updated_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefund { - #[primary_key] - pub(crate) out_refund_no: String, - #[unique] - pub(crate) provider_refund_id: String, - pub(crate) order_id: String, - pub(crate) provider_transaction_id: String, - pub(crate) user_id: Option, - pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, - pub(crate) total_cents: u64, - pub(crate) refund_cents: u64, - pub(crate) payer_total_cents: u64, - pub(crate) payer_refund_cents: u64, - pub(crate) success_at: Option, - pub(crate) first_observed_at: Timestamp, - pub(crate) updated_at: Timestamp, - pub(crate) last_observation_source: RuntimeProfileRechargeRefundObservationSource, - pub(crate) last_observation_id: String, - pub(crate) order_settled_at: Option, - pub(crate) target_recovery_points: u64, - pub(crate) recovered_points: u64, - pub(crate) unrecovered_points: u64, - pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, - pub(crate) last_recovery_ledger_id: Option, - pub(crate) last_error_code: Option, - #[default(None::)] - pub(crate) manual_review_resolved_by_admin_user_id: Option, - #[default(None::)] - pub(crate) manual_review_resolution_reason: Option, - #[default(None::)] - pub(crate) manual_review_resolved_at: Option, - #[default(None::)] - pub(crate) manual_review_resolved_error_code: Option, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund_observation, - index( - accessor = by_profile_recharge_refund_observation_refund, - btree(columns = [out_refund_no, observed_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefundObservation { - #[primary_key] - pub(crate) observation_id: String, - pub(crate) out_refund_no: String, - pub(crate) provider_refund_id: String, - pub(crate) order_id: String, - pub(crate) provider_transaction_id: String, - pub(crate) source: RuntimeProfileRechargeRefundObservationSource, - pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, - pub(crate) total_cents: u64, - pub(crate) refund_cents: u64, - pub(crate) payer_total_cents: u64, - pub(crate) payer_refund_cents: u64, - pub(crate) success_at: Option, - pub(crate) notification_ref: Option, - pub(crate) payload_fingerprint: String, - pub(crate) resolution_code: String, - pub(crate) observed_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_refund_settlement, - index( - accessor = by_profile_recharge_order_refund_settlement_user_id, - btree(columns = [user_id]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderRefundSettlement { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) successful_refund_count: u32, - pub(crate) cumulative_success_refund_cents: u64, - pub(crate) target_recovery_points: u64, - pub(crate) recovered_points: u64, - pub(crate) unrecovered_points: u64, - pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, - pub(crate) wallet_frozen: bool, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_refund_hold, - index(accessor = by_profile_recharge_refund_hold_order_id, btree(columns = [order_id])), - index(accessor = by_profile_recharge_refund_hold_user_id, btree(columns = [user_id])), - index(accessor = by_profile_recharge_refund_hold_status, btree(columns = [status])) -)] -#[derive(Clone)] -pub struct ProfileRechargeRefundHold { - #[primary_key] - pub(crate) out_refund_no: String, - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) refund_cents: u64, - pub(crate) held_points: u64, - pub(crate) status: RuntimeProfileRechargeRefundHoldStatus, - pub(crate) admin_user_id: String, - pub(crate) reason: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, - pub(crate) settled_at: Option, - pub(crate) released_at: Option, - pub(crate) released_by_admin_user_id: Option, - pub(crate) release_reason: Option, -} - -#[spacetimedb::table(accessor = profile_wallet_manual_restriction)] -#[derive(Clone)] -pub struct ProfileWalletManualRestriction { - #[primary_key] - pub(crate) user_id: String, - pub(crate) frozen: bool, - pub(crate) reason: String, - pub(crate) created_by_admin_user_id: String, - pub(crate) created_at: Timestamp, - pub(crate) updated_by_admin_user_id: String, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table(accessor = profile_recharge_refund_bill_checkpoint)] -#[derive(Clone)] -pub struct ProfileRechargeRefundBillCheckpoint { - #[primary_key] - pub(crate) checkpoint_id: String, - pub(crate) bill_date: String, - pub(crate) bill_hash: String, - pub(crate) processed_refund_count: u32, - pub(crate) completed_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_expiration_schedule, - index( - accessor = by_profile_recharge_order_expiration_scheduled_at, - btree(columns = [scheduled_at]) - ) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderExpirationSchedule { - #[primary_key] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) scheduled_at: Timestamp, - #[default(None::)] - pub(crate) lease_owner: Option, - #[default(None::)] - pub(crate) lease_expires_at: Option, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_recharge_order_expiration_timer, - scheduled(expire_profile_recharge_order_timer) -)] -#[derive(Clone)] -pub struct ProfileRechargeOrderExpirationTimer { - #[primary_key] - #[auto_inc] - pub(crate) scheduled_id: u64, - #[unique] - pub(crate) order_id: String, - pub(crate) user_id: String, - pub(crate) scheduled_at: ScheduleAt, - pub(crate) created_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_feedback_submission, - index(accessor = by_profile_feedback_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_feedback_user_created_at, - btree(columns = [user_id, created_at]) - ) -)] -pub struct ProfileFeedbackSubmission { - #[primary_key] - pub(crate) feedback_id: String, - pub(crate) user_id: String, - pub(crate) description: String, - pub(crate) contact_phone: Option, - // 中文注释:首版凭证以 Data URL 写入私有表,HTTP 回包只返回元数据,后续迁 OSS 不改变外部契约。 - pub(crate) evidence_json: String, - pub(crate) status: RuntimeProfileFeedbackStatus, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -#[spacetimedb::table( - accessor = profile_save_archive, - index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), - index( - accessor = by_profile_save_archive_user_world_key, - btree(columns = [user_id, world_key]) - ), - index( - accessor = by_profile_save_archive_user_saved_at, - btree(columns = [user_id, saved_at]) - ) -)] -pub struct ProfileSaveArchive { - #[primary_key] - pub(crate) archive_id: String, - pub(crate) user_id: String, - pub(crate) world_key: String, - pub(crate) owner_user_id: Option, - pub(crate) profile_id: Option, - pub(crate) world_type: Option, - pub(crate) world_name: String, - pub(crate) subtitle: String, - pub(crate) summary_text: String, - pub(crate) cover_image_src: Option, - pub(crate) saved_at: Timestamp, - pub(crate) bottom_tab: String, - pub(crate) game_state_json: String, - pub(crate) current_story_json: Option, - pub(crate) created_at: Timestamp, - pub(crate) updated_at: Timestamp, -} - -// save archive 列表是按世界聚合后的最近一次快照视图,读取时只做排序,不再拼装默认值。 -#[spacetimedb::procedure] -pub fn list_profile_save_archives( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveListInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| list_profile_save_archive_rows(tx, input.clone())) { - Ok(entries) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries, - record: None, - current_snapshot: None, - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// resume 会把指定 archive 回填到当前 snapshot,并同步返回 entry + 当前 snapshot。 -#[spacetimedb::procedure] -pub fn resume_profile_save_archive_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> RuntimeProfileSaveArchiveProcedureResult { - match ctx.try_with_tx(|tx| resume_profile_save_archive_record(tx, input.clone())) { - Ok((record, current_snapshot)) => RuntimeProfileSaveArchiveProcedureResult { - ok: true, - entries: Vec::new(), - record: Some(record), - current_snapshot: Some(current_snapshot), - error_message: None, - }, - Err(message) => RuntimeProfileSaveArchiveProcedureResult { - ok: false, - entries: Vec::new(), - record: None, - current_snapshot: None, - error_message: Some(message), - }, - } -} - -// profile dashboard 当前先作为 projection 读入口返回默认零值,等待 runtime_snapshot 写链补齐刷新。 -#[spacetimedb::procedure] -pub fn get_profile_dashboard( - ctx: &mut ProcedureContext, - input: RuntimeProfileDashboardGetInput, -) -> RuntimeProfileDashboardProcedureResult { - match ctx.try_with_tx(|tx| get_profile_dashboard_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileDashboardProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileDashboardProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 钱包流水当前只暴露最近 50 条只读视图,排序与截断逻辑在 procedure 内统一收口。 -#[spacetimedb::procedure] -pub fn list_profile_wallet_ledger( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletLedgerListInput, -) -> RuntimeProfileWalletLedgerProcedureResult { - match ctx.try_with_tx(|tx| list_profile_wallet_ledger_entries(tx, input.clone())) { - Ok(entries) => RuntimeProfileWalletLedgerProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileWalletLedgerProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -// analytics metric 查询直接聚合 tracking_daily_stat,避免 API 层订阅全量表后自行汇总。 -#[spacetimedb::procedure] -pub fn query_analytics_metric( - ctx: &mut ProcedureContext, - input: AnalyticsMetricQueryInput, -) -> AnalyticsMetricQueryProcedureResult { - match ctx.try_with_tx(|tx| query_analytics_metric_buckets(tx, input.clone())) { - Ok(buckets) => AnalyticsMetricQueryProcedureResult { - ok: true, - buckets, - error_message: None, - }, - Err(message) => AnalyticsMetricQueryProcedureResult { - ok: false, - buckets: Vec::new(), - error_message: Some(message), - }, - } -} - -// 通用埋点入口开放给 Axum 调用;具体入口仍在业务 handler 成功后显式触发。 -#[spacetimedb::procedure] -pub fn record_tracking_event_and_return( - ctx: &mut ProcedureContext, - input: RuntimeTrackingEventInput, -) -> RuntimeTrackingEventProcedureResult { - match ctx.try_with_tx(|tx| record_tracking_event(tx, input.clone())) { - Ok(()) => RuntimeTrackingEventProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeTrackingEventProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -// 高频 route tracking 由 api-server 本机 outbox 批量写入,减少公开列表热路径上的 procedure 调用次数。 -#[spacetimedb::procedure] -pub fn record_tracking_events_and_return( - ctx: &mut ProcedureContext, - inputs: Vec, -) -> RuntimeTrackingEventBatchProcedureResult { - match ctx.try_with_tx(|tx| { - let mut accepted_count = 0u32; - for input in &inputs { - record_tracking_event(tx, input.clone())?; - accepted_count = accepted_count.saturating_add(1); - } - Ok(accepted_count) - }) { - Ok(accepted_count) => RuntimeTrackingEventBatchProcedureResult { - ok: true, - accepted_count, - error_message: None, - }, - Err(message) => RuntimeTrackingEventBatchProcedureResult { - ok: false, - accepted_count: 0, - error_message: Some(message), - }, - } -} - -// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。 -#[spacetimedb::procedure] -pub fn record_daily_login_tracking_event_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskCenterGetInput, -) -> RuntimeTrackingEventProcedureResult { - match ctx.try_with_tx(|tx| { - let validated_input = build_runtime_profile_task_center_get_input(input.user_id.clone()) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(tx); - record_daily_login_tracking_event(tx, &validated_input.user_id) - }) { - Ok(()) => RuntimeTrackingEventProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeTrackingEventProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -// 任务中心读取会刷新进度;每日登录埋点应由登录成功链路提前记录。 -#[spacetimedb::procedure] -pub fn get_profile_task_center( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskCenterGetInput, -) -> RuntimeProfileTaskCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) { - Ok(record) => RuntimeProfileTaskCenterProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskCenterProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 领奖记录与泥点流水在同一事务内写入,避免任务状态和钱包余额漂移。 -#[spacetimedb::procedure] -pub fn claim_profile_task_reward_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskClaimInput, -) -> RuntimeProfileTaskClaimProcedureResult { - match ctx.try_with_tx(|tx| claim_profile_task_reward_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskClaimProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskClaimProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_task_configs( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminListInput, -) -> RuntimeProfileTaskConfigAdminListProcedureResult { - match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) { - Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_task_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminUpsertInput, -) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_disable_profile_task_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileTaskConfigAdminDisableInput, -) -> RuntimeProfileTaskConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileTaskConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_get_profile_wallet_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletConfigAdminGetInput, -) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| get_profile_wallet_config_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_wallet_config( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletConfigAdminUpsertInput, -) -> RuntimeProfileWalletConfigAdminProcedureResult { - match ctx.try_with_tx(|tx| upsert_profile_wallet_config_record(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletConfigAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[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( - ctx: &mut ProcedureContext, - input: RuntimeProfileDashboardGetInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - match ctx.try_with_tx(|tx| grant_new_user_registration_wallet_reward_tx(tx, input.clone())) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 资产生成由 Axum 调用外部模型,钱包扣费必须先在 SpacetimeDB 内原子落账。 -#[spacetimedb::procedure] -pub fn consume_profile_wallet_points_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletAdjustmentInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - apply_profile_wallet_adjustment( - tx, - input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - true, - ) - }) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 生成链路失败时由 Axum 调用退款,ledger_id 幂等保证重复补偿不会重复加钱。 -#[spacetimedb::procedure] -pub fn refund_profile_wallet_points_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletAdjustmentInput, -) -> RuntimeProfileWalletAdjustmentProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - apply_profile_wallet_adjustment( - tx, - input.clone(), - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, - ) - }) { - Ok(record) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileWalletAdjustmentProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// play stats 与 dashboard 共用 dashboard projection 的 total_play_time / updated_at,避免 Axum 侧拼装。 -#[spacetimedb::procedure] -pub fn get_profile_play_stats( - ctx: &mut ProcedureContext, - input: RuntimeProfilePlayStatsGetInput, -) -> RuntimeProfilePlayStatsProcedureResult { - match ctx.try_with_tx(|tx| get_profile_play_stats_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfilePlayStatsProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfilePlayStatsProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 账户充值中心只读快照,套餐和权益由后端返回,前端不保存业务价格表。 -#[spacetimedb::procedure] -pub fn get_profile_recharge_center( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeCenterGetInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_center_snapshot(tx, input.clone())) { - Ok(record) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: None, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderGetInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_recharge_order_snapshot(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn create_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderCreateInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| create_profile_recharge_order_record(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn mark_profile_recharge_order_paid_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderPaidInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - mark_profile_recharge_order_paid_record(tx, input.clone()) - }) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn record_profile_recharge_refund_observation_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundObservationInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - record_profile_recharge_refund_observation(tx, input.clone()) - }) { - Ok((record, settlement, duplicate, resolution_code)) => { - RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate, - resolution_code, - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_refund_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundGetInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = - build_runtime_profile_recharge_refund_get_input(input.out_refund_no.clone())?; - let row = tx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "profile_recharge_refund 不存在".to_string())?; - let settlement = tx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - )) - }) { - Ok((record, settlement)) => RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate: false, - resolution_code: "loaded".to_string(), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_profile_recharge_refunds_for_reconciliation( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundReconciliationListInput, -) -> RuntimeProfileRechargeRefundListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - Ok(list_profile_recharge_refund_reconciliation_rows( - tx, - input.clone(), - )) - }) { - Ok(entries) => RuntimeProfileRechargeRefundListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_recharge_orders_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderAdminListInput, -) -> RuntimeProfileRechargeOrderAdminListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - admin_list_profile_recharge_order_entries(tx, input.clone()) - }) { - Ok(entries) => RuntimeProfileRechargeOrderAdminListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderAdminListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn preview_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldPreviewInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - preview_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn prepare_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldPrepareInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - prepare_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn release_profile_recharge_refund_hold_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldReleaseInput, -) -> RuntimeProfileRechargeRefundHoldProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - release_profile_recharge_refund_hold(tx, input.clone()) - }) { - Ok((record, order, settlement, wallet)) => { - RuntimeProfileRechargeRefundHoldProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - settlement, - wallet: Some(wallet), - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { - ok: false, - record: None, - order: None, - settlement: None, - wallet: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn resolve_profile_recharge_refund_manual_review_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundManualReviewResolveInput, -) -> RuntimeProfileRechargeRefundProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - resolve_profile_recharge_refund_manual_review(tx, input.clone()) - }) { - Ok((record, settlement, duplicate, resolution_code)) => { - RuntimeProfileRechargeRefundProcedureResult { - ok: true, - record: Some(record), - settlement, - duplicate, - resolution_code, - error_message: None, - } - } - Err(message) => RuntimeProfileRechargeRefundProcedureResult { - ok: false, - record: None, - settlement: None, - duplicate: false, - resolution_code: "manual_review_resolution_failed".to_string(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_profile_recharge_refund_holds_for_reconciliation( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundHoldListInput, -) -> RuntimeProfileRechargeRefundHoldListProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_recharge_refund_hold_list_input(input.limit); - let mut rows = tx - .db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_status() - .filter(RuntimeProfileRechargeRefundHoldStatus::Active) - .collect::>(); - rows.sort_by(|left, right| { - left.updated_at - .to_micros_since_unix_epoch() - .cmp(&right.updated_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - let rotation_slot = tx - .timestamp - .to_micros_since_unix_epoch() - .div_euclid(60 * 1_000_000) - .unsigned_abs(); - Ok(select_profile_recharge_refund_hold_reconciliation_page( - rows, - validated.limit as usize, - rotation_slot, - ) - .into_iter() - .map(|row| build_profile_recharge_refund_hold_snapshot_from_row(&row)) - .collect()) - }) { - Ok(entries) => RuntimeProfileRechargeRefundHoldListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundHoldListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_get_profile_wallet_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileAdminWalletGetInput, -) -> RuntimeProfileAdminWalletProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_admin_wallet_get_input(input.user_id.clone())?; - Ok(build_profile_admin_wallet_snapshot(tx, &validated.user_id)) - }) { - Ok(record) => RuntimeProfileAdminWalletProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileAdminWalletProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_wallet_manual_restriction_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileWalletManualRestrictionUpsertInput, -) -> RuntimeProfileAdminWalletProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_wallet_manual_restriction_upsert_input( - input.user_id.clone(), - input.frozen, - input.reason.clone(), - input.admin_user_id.clone(), - )?; - upsert_profile_wallet_manual_restriction(tx, validated); - Ok(build_profile_admin_wallet_snapshot(tx, &input.user_id)) - }) { - Ok(record) => RuntimeProfileAdminWalletProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileAdminWalletProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn get_profile_recharge_refund_bill_checkpoint_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundBillCheckpointGetInput, -) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - let validated = build_runtime_profile_recharge_refund_bill_checkpoint_get_input( - input.checkpoint_id.clone(), - )?; - Ok(tx - .db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .find(&validated.checkpoint_id) - .map(|row| build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row))) - }) { - Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: true, - record, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn advance_profile_recharge_refund_bill_checkpoint_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, -) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - let caller = ctx.sender(); - match ctx.try_with_tx(|tx| { - crate::editor_project_storage::require_editor_generation_runtime_service_identity( - tx, caller, - )?; - advance_profile_recharge_refund_bill_checkpoint(tx, input.clone()) - }) { - Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn claim_profile_recharge_order_expiration_schedule_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationClaimInput, -) -> RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - match ctx.try_with_tx(|tx| claim_profile_recharge_order_expiration_schedules(tx, input.clone())) - { - Ok(entries) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationClaimProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn complete_profile_recharge_order_expiration_schedule_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCompleteInput, -) -> RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - match ctx - .try_with_tx(|tx| complete_profile_recharge_order_expiration_schedule(tx, input.clone())) - { - Ok(()) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - ok: true, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCompleteProcedureResult { - ok: false, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn list_unchecked_expired_profile_recharge_orders( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCheckListInput, -) -> RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - match ctx - .try_with_tx(|tx| list_unchecked_expired_profile_recharge_order_rows(tx, input.clone())) - { - Ok(entries) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - ok: true, - entries, - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCheckListProcedureResult { - ok: false, - entries: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn mark_profile_recharge_order_expiration_checked( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderExpirationCheckInput, -) -> RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - match ctx - .try_with_tx(|tx| mark_profile_recharge_order_expiration_checked_record(tx, input.clone())) - { - Ok(record) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeOrderExpirationCheckProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::reducer] -pub fn expire_profile_recharge_order_timer( - ctx: &ReducerContext, - timer: ProfileRechargeOrderExpirationTimer, -) -> Result<(), String> { - if !ctx.sender_auth().is_internal() { - return Err("profile_recharge_order_expiration_timer scheduler-only".to_string()); - } - - let Some(mut order) = ctx - .db - .profile_recharge_order() - .order_id() - .find(&timer.order_id) - else { - delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id); - return Ok(()); - }; - - if order.status == RuntimeProfileRechargeOrderStatus::Pending { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Expired; - order.expired_at = Some(ctx.timestamp); - ctx.db.profile_recharge_order().insert(order); - } - delete_profile_recharge_order_expiration_timer(ctx, &timer.order_id); - Ok(()) -} - -#[spacetimedb::procedure] -pub fn close_profile_recharge_order_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileRechargeOrderCloseInput, -) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| close_profile_recharge_order_record(tx, input.clone())) { - Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { - ok: true, - record: Some(record), - order: Some(order), - error_message: None, - }, - Err(message) => RuntimeProfileRechargeCenterProcedureResult { - ok: false, - record: None, - order: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn submit_profile_feedback_and_return( - ctx: &mut ProcedureContext, - input: RuntimeProfileFeedbackSubmissionInput, -) -> RuntimeProfileFeedbackSubmissionProcedureResult { - match ctx.try_with_tx(|tx| submit_profile_feedback_record(tx, input.clone())) { - Ok(record) => RuntimeProfileFeedbackSubmissionProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileFeedbackSubmissionProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。 -#[spacetimedb::procedure] -pub fn get_profile_referral_invite_center( - ctx: &mut ProcedureContext, - input: RuntimeReferralInviteCenterGetInput, -) -> RuntimeReferralInviteCenterProcedureResult { - match ctx.try_with_tx(|tx| get_profile_referral_invite_center_snapshot(tx, input.clone())) { - Ok(record) => RuntimeReferralInviteCenterProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeReferralInviteCenterProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 填码绑定、每日邀请者奖励上限和双方泥点发放都在同一事务内完成。 -#[spacetimedb::procedure] -pub fn redeem_profile_referral_invite_code( - ctx: &mut ProcedureContext, - input: RuntimeReferralRedeemInput, -) -> RuntimeReferralRedeemProcedureResult { - match ctx.try_with_tx(|tx| redeem_profile_referral_invite_code_record(tx, input.clone())) { - Ok(record) => RuntimeReferralRedeemProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeReferralRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -// 兑换码奖励、usage 与钱包流水必须在同一事务内落库,避免到账和计次分离。 -#[spacetimedb::procedure] -pub fn redeem_profile_reward_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRewardCodeRedeemInput, -) -> RuntimeProfileRewardCodeRedeemProcedureResult { - match ctx.try_with_tx(|tx| redeem_profile_reward_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRewardCodeRedeemProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_redeem_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> RuntimeProfileRedeemCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_upsert_profile_redeem_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_disable_profile_redeem_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> RuntimeProfileRedeemCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_disable_profile_redeem_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_redeem_codes( - ctx: &mut ProcedureContext, - input: RuntimeProfileRedeemCodeAdminListInput, -) -> RuntimeProfileRedeemCodeAdminListProcedureResult { - match ctx.try_with_tx(|tx| admin_list_profile_redeem_code_records(tx, input.clone())) { - Ok((entries, operations)) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: true, - entries, - operations, - error_message: None, - }, - Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_upsert_profile_invite_code( - ctx: &mut ProcedureContext, - input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> RuntimeProfileInviteCodeAdminProcedureResult { - match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) { - Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: true, - record: Some(record), - error_message: None, - }, - Err(message) => RuntimeProfileInviteCodeAdminProcedureResult { - ok: false, - record: None, - error_message: Some(message), - }, - } -} - -#[spacetimedb::procedure] -pub fn admin_list_profile_invite_codes( - ctx: &mut ProcedureContext, - input: RuntimeProfileInviteCodeAdminListInput, -) -> RuntimeProfileInviteCodeAdminListProcedureResult { - match ctx.try_with_tx(|tx| admin_list_profile_invite_code_records(tx, input.clone())) { - Ok((entries, operations)) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: true, - entries, - operations, - error_message: None, - }, - Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult { - ok: false, - entries: Vec::new(), - operations: Vec::new(), - error_message: Some(message), - }, - } -} - -pub(crate) fn list_profile_save_archive_rows( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveListInput, -) -> Result, String> { - let validated_input = build_runtime_profile_save_archive_list_input(input.user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_save_archive() - .by_profile_save_archive_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_save_archive_snapshot_from_row(&row)) - .collect::>(); - - entries.sort_by(|left, right| { - right - .saved_at_micros - .cmp(&left.saved_at_micros) - .then_with(|| left.archive_id.cmp(&right.archive_id)) - }); - - Ok(entries) -} - -pub(crate) fn resume_profile_save_archive_record( - ctx: &ReducerContext, - input: RuntimeProfileSaveArchiveResumeInput, -) -> Result<(RuntimeProfileSaveArchiveSnapshot, RuntimeSnapshot), String> { - let validated_input = - build_runtime_profile_save_archive_resume_input(input.user_id, input.world_key) - .map_err(|error| error.to_string())?; - let archive = ctx - .db - .profile_save_archive() - .by_profile_save_archive_user_world_key() - .filter(( - validated_input.user_id.as_str(), - validated_input.world_key.as_str(), - )) - .next() - .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; - - let existing_snapshot = ctx - .db - .runtime_snapshot() - .user_id() - .find(&validated_input.user_id); - let created_at = existing_snapshot - .as_ref() - .map(|row| row.created_at) - .unwrap_or(archive.saved_at); - - if let Some(existing) = existing_snapshot { - ctx.db - .runtime_snapshot() - .user_id() - .delete(&existing.user_id); - } - - ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at: archive.saved_at, - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at, - updated_at: archive.saved_at, - }); - - Ok(( - build_profile_save_archive_snapshot_from_row(&archive), - RuntimeSnapshot { - user_id: archive.user_id.clone(), - version: SAVE_SNAPSHOT_VERSION, - saved_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - bottom_tab: archive.bottom_tab.clone(), - game_state_json: archive.game_state_json.clone(), - current_story_json: archive.current_story_json.clone(), - created_at_micros: created_at.to_micros_since_unix_epoch(), - updated_at_micros: archive.saved_at.to_micros_since_unix_epoch(), - }, - )) -} - -pub(crate) fn sync_profile_projections_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, -) -> Result<(), String> { - let game_state = parse_json_str(&snapshot.game_state_json)?; - let game_state_object = game_state.as_object(); - let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros); - - if module_runtime::is_non_persistent_runtime_snapshot(&game_state) { - return Ok(()); - } - - sync_profile_dashboard_from_snapshot(ctx, snapshot, game_state_object, saved_at); - sync_profile_save_archive_from_snapshot(ctx, snapshot, &game_state, saved_at)?; - - Ok(()) -} - -pub(crate) fn upsert_profile_played_work( - ctx: &ReducerContext, - input: ProfilePlayedWorkUpsertInput, -) -> Result<(), String> { - let user_id = input.user_id.trim(); - let world_key = input.world_key.trim(); - if user_id.is_empty() { - return Err("profile_played_world.user_id 不能为空".to_string()); - } - if world_key.is_empty() { - return Err("profile_played_world.world_key 不能为空".to_string()); - } - - let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); - let played_world_id = build_runtime_profile_played_world_id(user_id, world_key); - let existing = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id); - - if let Some(existing) = existing { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_title: input.world_title, - world_subtitle: input.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: played_at, - last_observed_play_time_ms: existing.last_observed_play_time_ms, - }); - } else { - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_title: input.world_title, - world_subtitle: input.world_subtitle, - first_played_at: played_at, - last_played_at: played_at, - last_observed_play_time_ms: 0, - }); - } - - ensure_profile_dashboard_state(ctx, user_id, played_at); - Ok(()) -} - -pub(crate) fn add_profile_observed_play_time( - ctx: &ReducerContext, - user_id: &str, - world_key: &str, - elapsed_ms: u64, - observed_at_micros: i64, -) -> Result<(), String> { - let user_id = user_id.trim(); - let world_key = world_key.trim(); - if user_id.is_empty() || world_key.is_empty() || elapsed_ms == 0 { - return Ok(()); - } - - let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros); - let played_world_id = build_runtime_profile_played_world_id(user_id, world_key); - if let Some(existing) = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id) - { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: existing.user_id, - world_key: existing.world_key, - owner_user_id: existing.owner_user_id, - profile_id: existing.profile_id, - world_type: existing.world_type, - world_title: existing.world_title, - world_subtitle: existing.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: observed_at, - last_observed_play_time_ms: existing - .last_observed_play_time_ms - .saturating_add(elapsed_ms), - }); - } - - add_profile_dashboard_play_time(ctx, user_id, elapsed_ms, observed_at); - Ok(()) -} - -pub(crate) fn upsert_profile_save_archive( - ctx: &ReducerContext, - input: ProfileSaveArchiveUpsertInput, -) -> Result<(), String> { - let user_id = input.user_id.trim(); - let world_key = input.world_key.trim(); - if user_id.is_empty() || world_key.is_empty() { - return Err("profile_save_archive 参数不能为空".to_string()); - } - - let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); - let archive_id = format!("{user_id}:{world_key}"); - let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(saved_at); - - if let Some(existing) = existing { - ctx.db - .profile_save_archive() - .archive_id() - .delete(&existing.archive_id); - } - - ctx.db.profile_save_archive().insert(ProfileSaveArchive { - archive_id, - user_id: user_id.to_string(), - world_key: world_key.to_string(), - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_type: input.world_type, - world_name: input.world_name, - subtitle: input.subtitle, - summary_text: input.summary_text, - cover_image_src: input.cover_image_src, - saved_at, - bottom_tab: input.bottom_tab, - game_state_json: input.game_state_json, - current_story_json: input.current_story_json, - created_at, - updated_at: saved_at, - }); - - Ok(()) -} - -pub(crate) fn record_public_work_play( - ctx: &ReducerContext, - input: PublicWorkPlayRecordInput, -) -> Result<(), String> { - let source_type = input.source_type.trim(); - let owner_user_id = input.owner_user_id.trim(); - let profile_id = input.profile_id.trim(); - if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() { - return Err("public_work_play_daily_stat 参数不能为空".to_string()); - } - - let played_day = public_work_play_day_from_micros(input.played_at_micros); - let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day); - let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); - let next_count = ctx - .db - .public_work_play_daily_stat() - .stat_id() - .find(&stat_id) - .map(|existing| { - ctx.db - .public_work_play_daily_stat() - .stat_id() - .delete(&existing.stat_id); - existing.play_count.saturating_add(1) - }) - .unwrap_or(1); - - ctx.db - .public_work_play_daily_stat() - .insert(PublicWorkPlayDailyStat { - stat_id, - source_type: source_type.to_string(), - owner_user_id: owner_user_id.to_string(), - profile_id: profile_id.to_string(), - played_day, - play_count: next_count, - updated_at, - }); - - Ok(()) -} - -pub(crate) fn record_public_work_like( - ctx: &ReducerContext, - input: PublicWorkLikeRecordInput, -) -> Result { - let source_type = input.source_type.trim(); - let owner_user_id = input.owner_user_id.trim(); - let profile_id = input.profile_id.trim(); - let user_id = input.user_id.trim(); - if source_type.is_empty() - || owner_user_id.is_empty() - || profile_id.is_empty() - || user_id.is_empty() - { - return Err("public_work_like 参数不能为空".to_string()); - } - - let like_id = build_public_work_like_id(source_type, profile_id, user_id); - if ctx.db.public_work_like().like_id().find(&like_id).is_some() { - return Ok(false); - } - - ctx.db.public_work_like().insert(PublicWorkLike { - like_id, - source_type: source_type.to_string(), - owner_user_id: owner_user_id.to_string(), - profile_id: profile_id.to_string(), - user_id: user_id.to_string(), - liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros), - }); - - Ok(true) -} - -pub(crate) fn count_recent_public_work_plays( - ctx: &ReducerContext, - source_type: &str, - profile_id: &str, - now_micros: i64, -) -> u32 { - count_recent_public_work_plays_for_profiles( - ctx, - source_type, - &[profile_id.to_string()], - now_micros, - ) - .remove(profile_id.trim()) - .unwrap_or(0) -} - -pub(crate) fn count_recent_public_work_plays_for_profiles( - ctx: &ReducerContext, - source_type: &str, - profile_ids: &[String], - now_micros: i64, -) -> HashMap { - let source_type = source_type.trim(); - if source_type.is_empty() || profile_ids.is_empty() { - return HashMap::new(); - } - - let current_day = public_work_play_day_from_micros(now_micros); - let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - let requested_profile_ids = profile_ids - .iter() - .map(|profile_id| profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .collect::>(); - let mut counts = HashMap::new(); - - for profile_id in requested_profile_ids { - let mut total = 0u32; - for played_day in first_day..=current_day { - let day_total = ctx - .db - .public_work_play_daily_stat() - .by_public_work_play_daily_stat_work_day() - .filter((source_type, profile_id, played_day)) - .fold(0u32, |sum, row| sum.saturating_add(row.play_count)); - total = total.saturating_add(day_total); - } - if total > 0 { - counts.insert(profile_id.to_string(), total); - } - } - - counts -} - -#[cfg(test)] -fn build_recent_public_work_play_counts( - rows: impl IntoIterator, - source_type: &str, - profile_ids: &[String], - now_micros: i64, -) -> HashMap { - let source_type = source_type.trim(); - if source_type.is_empty() || profile_ids.is_empty() { - return HashMap::new(); - } - - let requested_profile_ids = profile_ids - .iter() - .map(|profile_id| profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .collect::>(); - if requested_profile_ids.is_empty() { - return HashMap::new(); - } - - let current_day = public_work_play_day_from_micros(now_micros); - let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); - let mut counts = HashMap::new(); - - for row in rows { - if row.source_type != source_type - || !requested_profile_ids.contains(row.profile_id.as_str()) - || row.played_day < first_day - || row.played_day > current_day - { - continue; - } - - let entry = counts.entry(row.profile_id.clone()).or_insert(0u32); - *entry = entry.saturating_add(row.play_count); - } - - counts -} - -fn public_work_play_day_from_micros(value: i64) -> i64 { - value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) -} - -fn build_public_work_play_daily_stat_id( - source_type: &str, - profile_id: &str, - played_day: i64, -) -> String { - format!("{source_type}:{profile_id}:{played_day}") -} - -fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String { - format!("{source_type}:{profile_id}:{user_id}") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn wallet_ledger_snapshot( - ledger_id: &str, - amount_delta: i64, - balance_after: u64, - created_at_micros: i64, - ) -> RuntimeProfileWalletLedgerEntrySnapshot { - RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: ledger_id.to_string(), - user_id: "user-1".to_string(), - amount_delta, - balance_after, - source_type: RuntimeProfileWalletLedgerSourceType::PointsRecharge, - created_at_micros, - metadata_json: "{}".to_string(), - } - } - - #[test] - fn wallet_ledger_sort_follows_balance_settlement_chain_when_payment_time_is_delayed() { - let mut entries = vec![ - wallet_ledger_snapshot("daily-free", 20, 97, 1), - wallet_ledger_snapshot("recharge-180-delayed", 180, 607, 2), - wallet_ledger_snapshot("recharge-60", 60, 157, 3), - wallet_ledger_snapshot("recharge-270", 270, 427, 4), - ]; - - sort_profile_wallet_ledger_entries(&mut entries, 607); - - assert_eq!( - entries - .iter() - .map(|entry| entry.wallet_ledger_id.as_str()) - .collect::>(), - vec![ - "recharge-180-delayed", - "recharge-270", - "recharge-60", - "daily-free", - ] - ); - } - - #[test] - fn wallet_ledger_records_settlement_time_instead_of_delayed_business_event_time() { - let paid_at = Timestamp::from_micros_since_unix_epoch(100); - let settled_at = Timestamp::from_micros_since_unix_epoch(200); - - assert_eq!( - profile_wallet_ledger_recorded_at(paid_at, settled_at), - settled_at - ); - } - - fn asset_operation_wallet_ledger( - ledger_id: &str, - user_id: &str, - amount_delta: i64, - source_type: RuntimeProfileWalletLedgerSourceType, - ) -> ProfileWalletLedger { - ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta, - balance_after: 100, - source_type, - created_at: Timestamp::from_micros_since_unix_epoch(1), - metadata_json: None, - } - } - - #[test] - fn asset_operation_refund_without_matching_consume_records_settlement_intent() { - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - "asset_operation_refund:external_generation_job:job-1:attempt:1", - None, - None, - None, - ), - Ok(AssetOperationRefundDisposition::RecordIntent) - ); - } - - #[test] - fn asset_operation_refund_is_idempotent_and_requires_matching_consume() { - let consume = asset_operation_wallet_ledger( - "asset_operation_consume:external_generation_job:job-1:attempt:1", - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ); - let refund = asset_operation_wallet_ledger( - "asset_operation_refund:external_generation_job:job-1:attempt:1", - "user-1", - 37, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - ); - - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ), - Ok(AssetOperationRefundDisposition::Apply) - ); - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - &refund.wallet_ledger_id, - None, - Some(&refund), - Some(&consume), - ), - Ok(AssetOperationRefundDisposition::Noop) - ); - assert!( - resolve_asset_operation_refund_disposition( - "user-2", - 37, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ) - .is_err() - ); - assert!( - resolve_asset_operation_refund_disposition( - "user-1", - 38, - &refund.wallet_ledger_id, - None, - None, - Some(&consume), - ) - .is_err() - ); - } - - #[test] - fn asset_operation_settlement_intent_rejects_conflicting_late_operations() { - let consume_ledger_id = "asset_operation_consume:external_generation_job:job-1:attempt:1"; - let refund_ledger_id = "asset_operation_refund:external_generation_job:job-1:attempt:1"; - let settlement = AssetOperationWalletSettlement { - consume_ledger_id: consume_ledger_id.to_string(), - refund_ledger_id: refund_ledger_id.to_string(), - user_id: "user-1".to_string(), - amount: 37, - settled_at: Timestamp::from_micros_since_unix_epoch(1), - }; - - assert_eq!( - resolve_asset_operation_refund_disposition( - "user-1", - 37, - refund_ledger_id, - Some(&settlement), - None, - None, - ), - Ok(AssetOperationRefundDisposition::Noop) - ); - assert!( - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - refund_ledger_id, - "user-2", - 37, - ) - .is_err() - ); - assert!( - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - refund_ledger_id, - "user-1", - 38, - ) - .is_err() - ); - } - - #[test] - fn wallet_idempotent_replay_requires_matching_user_amount_and_source() { - let existing = asset_operation_wallet_ledger( - "asset_operation_consume:external_generation_job:job-1:attempt:1", - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ); - - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_ok() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-2", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_err() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -38, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - ) - .is_err() - ); - assert!( - validate_idempotent_profile_wallet_ledger( - &existing, - "user-1", - -37, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - ) - .is_err() - ); - } - - fn recharge_order_for_refund( - status: RuntimeProfileRechargeOrderStatus, - ) -> ProfileRechargeOrder { - ProfileRechargeOrder { - order_id: "rcg-test".to_string(), - user_id: "user-1".to_string(), - product_id: "points_60".to_string(), - product_title: "60泥点".to_string(), - kind: RuntimeProfileRechargeProductKind::Points, - amount_cents: 600, - status, - payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE.to_string(), - paid_at: Some(Timestamp::from_micros_since_unix_epoch(100)), - provider_transaction_id: Some("wx-transaction-1".to_string()), - created_at: Timestamp::from_micros_since_unix_epoch(1), - points_delta: 60, - membership_expires_at: None, - expired_at: None, - expiration_checked_at: None, - expiration_provider_state: None, - expiration_last_error: None, - } - } - - fn recharge_refund_for_order(order: &ProfileRechargeOrder) -> ProfileRechargeRefund { - ProfileRechargeRefund { - out_refund_no: "refund-test".to_string(), - provider_refund_id: "wx-refund-1".to_string(), - order_id: order.order_id.clone(), - provider_transaction_id: order.provider_transaction_id.clone().unwrap(), - user_id: Some(order.user_id.clone()), - provider_status: RuntimeProfileRechargeRefundStatus::Success, - total_cents: order.amount_cents, - refund_cents: order.amount_cents, - payer_total_cents: order.amount_cents, - payer_refund_cents: order.amount_cents, - success_at: Some(Timestamp::from_micros_since_unix_epoch(200)), - first_observed_at: Timestamp::from_micros_since_unix_epoch(200), - updated_at: Timestamp::from_micros_since_unix_epoch(200), - last_observation_source: RuntimeProfileRechargeRefundObservationSource::Callback, - last_observation_id: "observation-1".to_string(), - order_settled_at: None, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, - last_recovery_ledger_id: None, - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - manual_review_resolved_error_code: None, - } - } - - #[test] - fn refunded_order_accepts_only_exact_payment_transaction_replay() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); - assert!( - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &Some("wx-transaction-1".to_string()), - ) - .is_ok() - ); - assert!( - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &Some("wx-transaction-other".to_string()), - ) - .is_err() - ); - assert!(validate_profile_recharge_order_paid_replay_transaction_id(&order, &None).is_err()); - } - - #[test] - fn confirmed_manual_review_suppresses_only_the_approved_order_match_conflict() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.provider_transaction_id = "wx-transaction-other".to_string(); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("provider_transaction_id_mismatch".to_string()) - ); - - refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); - refund.manual_review_resolved_error_code = - Some("provider_transaction_id_mismatch".to_string()); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - None - ); - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &refund), - Err("provider_transaction_id_mismatch".to_string()) - ); - - refund.total_cents = order.amount_cents + 1; - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("order_total_mismatch".to_string()) - ); - - refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); - assert_eq!( - unresolved_profile_recharge_refund_order_match_error(&order, &refund), - Some("provider_transaction_id_mismatch".to_string()) - ); - } - - #[test] - fn manual_review_request_matches_current_and_resolved_state() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.last_error_code = Some("provider_transaction_id_mismatch".to_string()); - - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "provider_transaction_id_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_ok() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_err() - ); - - refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); - refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); - refund.manual_review_resolved_by_admin_user_id = Some("admin-1".to_string()); - refund.manual_review_resolution_reason = Some("已核对商户平台".to_string()); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_ok() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "provider_transaction_id_mismatch", - "admin-1", - "已核对商户平台", - ) - .is_err() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-2", - "已核对商户平台", - ) - .is_err() - ); - assert!( - validate_profile_recharge_refund_manual_review_request( - &refund, - "order_total_mismatch", - "admin-1", - "另一份核对意见", - ) - .is_err() - ); - } - - #[test] - fn refunded_order_keeps_first_recharge_qualification_consumed() { - let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); - assert!(profile_recharge_order_counts_as_paid_purchase(&refunded)); - - let mut unpaid = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); - unpaid.paid_at = None; - assert!(!profile_recharge_order_counts_as_paid_purchase(&unpaid)); - } - - #[test] - fn v3_refund_order_match_rejects_virtual_channel_transaction_and_total_mismatch() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let refund = recharge_refund_for_order(&order); - assert!(validate_profile_recharge_refund_order_match(&order, &refund).is_ok()); - - let mut wrong_channel = order.clone(); - wrong_channel.payment_channel = - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL.to_string(); - assert_eq!( - validate_profile_recharge_refund_order_match(&wrong_channel, &refund), - Err("payment_channel_not_v3".to_string()) - ); - - let mut wrong_transaction = refund.clone(); - wrong_transaction.provider_transaction_id = "wx-transaction-other".to_string(); - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &wrong_transaction), - Err("provider_transaction_id_mismatch".to_string()) - ); - - let mut wrong_total = refund; - wrong_total.total_cents = 599; - assert_eq!( - validate_profile_recharge_refund_order_match(&order, &wrong_total), - Err("order_total_mismatch".to_string()) - ); - } - - #[test] - fn successful_point_refund_conflict_freezes_ordinary_wallet_debits() { - for code in [ - "provider_transaction_id_mismatch", - "order_total_mismatch", - "refund_settlement_plan_invalid", - ] { - assert!(profile_recharge_refund_manual_review_freezes_wallet( - RuntimeProfileRechargeProductKind::Points, - code, - )); - assert!(!profile_recharge_refund_manual_review_freezes_wallet( - RuntimeProfileRechargeProductKind::Membership, - code, - )); - } - - let settlement = ProfileRechargeOrderRefundSettlement { - order_id: "rcg-test".to_string(), - user_id: "user-1".to_string(), - successful_refund_count: 0, - cumulative_success_refund_cents: 0, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::ManualReview, - wallet_frozen: true, - updated_at: Timestamp::from_micros_since_unix_epoch(200), - }; - let refund_frozen = profile_recharge_refund_settlement_freezes_wallet(&settlement); - assert!(refund_frozen); - assert!( - validate_runtime_profile_wallet_debit_restrictions( - -1, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - false, - refund_frozen, - ) - .is_err() - ); - } - - #[test] - fn refund_observation_replay_requires_identical_financial_facts() { - let input = build_runtime_profile_recharge_refund_observation_input( - "observation-1".to_string(), - RuntimeProfileRechargeRefundObservationSource::Callback, - Some("notification-ref".to_string()), - "payload-ref".to_string(), - "refund-test".to_string(), - "wx-refund-1".to_string(), - "rcg-test".to_string(), - "wx-transaction-1".to_string(), - RuntimeProfileRechargeRefundStatus::Success, - 600, - 600, - 600, - 600, - Some(200), - 300, - ) - .unwrap(); - let row = ProfileRechargeRefundObservation { - observation_id: input.observation_id.clone(), - out_refund_no: input.out_refund_no.clone(), - provider_refund_id: input.provider_refund_id.clone(), - order_id: input.order_id.clone(), - provider_transaction_id: input.provider_transaction_id.clone(), - source: input.source, - provider_status: input.provider_status, - total_cents: input.total_cents, - refund_cents: input.refund_cents, - payer_total_cents: input.payer_total_cents, - payer_refund_cents: input.payer_refund_cents, - success_at: input - .success_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - notification_ref: input.notification_ref.clone(), - payload_fingerprint: input.payload_fingerprint.clone(), - resolution_code: "settled".to_string(), - observed_at: Timestamp::from_micros_since_unix_epoch(input.observed_at_micros), - }; - assert!(validate_profile_recharge_refund_observation_replay(&row, &input).is_ok()); - - let mut conflicting = input; - conflicting.refund_cents = 599; - assert!(validate_profile_recharge_refund_observation_replay(&row, &conflicting).is_err()); - let snapshot = build_profile_recharge_refund_observation_snapshot_from_row(&row); - assert_eq!(snapshot.resolution_code, "settled"); - } - - #[test] - fn refund_reconciliation_retries_only_late_order_manual_review() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let mut refund = recharge_refund_for_order(&order); - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - - refund.last_error_code = Some("order_missing".to_string()); - assert!(profile_recharge_refund_needs_reconciliation(&refund)); - refund.last_error_code = Some("order_not_paid".to_string()); - assert!(profile_recharge_refund_needs_reconciliation(&refund)); - - refund.last_error_code = Some("membership_manual_review".to_string()); - assert!(!profile_recharge_refund_needs_reconciliation(&refund)); - refund.last_error_code = Some("order_total_mismatch".to_string()); - assert!(!profile_recharge_refund_needs_reconciliation(&refund)); - } - - #[test] - fn refund_reconciliation_rotates_across_more_than_one_batch() { - let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); - let rows = (0..250) - .map(|index| { - let mut refund = recharge_refund_for_order(&order); - refund.out_refund_no = format!("refund-{index:03}"); - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; - refund - }) - .collect::>(); - - let first = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 0); - let second = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 1); - let third = select_profile_recharge_refund_reconciliation_page(rows, 100, 2); - assert_eq!((first.len(), second.len(), third.len()), (100, 100, 50)); - - let observed = first - .into_iter() - .chain(second) - .chain(third) - .map(|row| row.out_refund_no) - .collect::>(); - assert_eq!(observed.len(), 250); - } - - #[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, 90); - assert_eq!(untouched.badge_label, "首充加赠"); - assert_eq!(untouched.description, "首充加赠90泥点"); - } - - #[test] - fn legacy_default_recharge_products_migrate_without_overwriting_admin_config() { - let timestamp = Timestamp::from_micros_since_unix_epoch(1_000_000); - let build_row = |points_amount: u64| ProfileRechargeProductConfig { - product_id: format!("points_{points_amount}"), - title: format!("{points_amount}泥点"), - price_cents: points_amount * 10, - kind: RuntimeProfileRechargeProductKind::Points, - points_amount, - bonus_points: points_amount, - duration_days: 0, - badge_label: "首充双倍".to_string(), - description: format!("首充送{points_amount}泥点"), - tier: RuntimeProfileMembershipTier::Normal, - enabled: true, - sort_order: 0, - created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - created_at: timestamp, - updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(), - updated_at: timestamp, - membership_period_points: 0, - membership_period_days: 0, - membership_queue_limit: 0, - membership_discount_bps: 0, - }; - - assert_eq!( - resolve_default_point_product_migration(&build_row(180)), - Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - ); - assert_eq!( - resolve_default_point_product_migration(&build_row(1_280)), - Some(DefaultPointProductMigration { - bonus_points: 1_280, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - ); - - let mut admin_row = build_row(300); - admin_row.updated_by = "admin-1".to_string(); - assert_eq!(resolve_default_point_product_migration(&admin_row), None); - } - - #[test] - fn membership_wallet_split_metadata_keeps_cycle_reset_for_refund_restore() { - let metadata = metadata_with_profile_wallet_consumption_split( - r#"{"externalGenerationJobId":"job-1"}"#, - 150, - DailyFreePointMutation::none(), - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(123_000), - }, - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!( - parsed - .get("externalGenerationJobId") - .and_then(JsonValue::as_str), - Some("job-1"), - ); - assert_eq!( - parsed - .get("dailyFreePointsDelta") - .and_then(JsonValue::as_i64), - Some(0), - ); - assert_eq!( - parsed - .get("membershipPeriodPointsDelta") - .and_then(JsonValue::as_i64), - Some(-120), - ); - assert_eq!( - parsed - .get("permanentPointsDelta") - .and_then(JsonValue::as_i64), - Some(-30), - ); - assert_eq!( - parsed - .get("cycleResetsAtMicros") - .and_then(JsonValue::as_i64), - Some(123_000), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata(&metadata), - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(123_000), - }, - ); - } - - #[test] - fn wallet_split_consumes_daily_free_before_membership_and_permanent_points() { - let metadata = metadata_with_profile_wallet_consumption_split( - "{}", - 150, - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - MembershipCyclePointMutation { - points: 120, - cycle_resets_at_micros: Some(456_000), - }, - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!(parsed["dailyFreePointsDelta"], json!(-20)); - assert_eq!(parsed["dailyFreeDayKey"], json!(20_280)); - assert_eq!(parsed["membershipPeriodPointsDelta"], json!(-120)); - assert_eq!(parsed["permanentPointsDelta"], json!(-10)); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata(&metadata), - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - ); - } - - #[test] - fn cross_day_daily_free_refund_keeps_the_original_permanent_remainder_permanent() { - let metadata = metadata_with_profile_wallet_refund_split( - "{}", - 30, - DailyFreePointMutation { - points: 20, - day_key: Some(20_281), - }, - MembershipCyclePointMutation::none(), - ); - let parsed = serde_json::from_str::(&metadata).expect("metadata json"); - - assert_eq!(parsed["dailyFreePointsDelta"], json!(20)); - assert_eq!(parsed["dailyFreeDayKey"], json!(20_281)); - assert_eq!(parsed["membershipPeriodPointsDelta"], json!(0)); - assert_eq!(parsed["permanentPointsDelta"], json!(10)); - } - - #[test] - fn daily_free_refund_restore_candidate_requires_a_negative_delta_and_day_key() { - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata( - r#"{"dailyFreePointsDelta":-20,"dailyFreeDayKey":20280}"#, - ), - DailyFreePointMutation { - points: 20, - day_key: Some(20_280), - }, - ); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata( - r#"{"dailyFreePointsDelta":0,"dailyFreeDayKey":20280}"#, - ), - DailyFreePointMutation::none(), - ); - assert_eq!( - daily_free_refund_restore_candidate_from_consume_metadata("not-json"), - DailyFreePointMutation::none(), - ); - } - - #[test] - fn daily_free_refund_restores_same_day_and_stacks_after_cross_day() { - assert_eq!( - resolve_daily_free_refund_restore_plan(20_280, 20_280, 20_280, 20, 5, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 15, - target_day_key: 20_280, - granted_points_delta: 0, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_280, 20_281, 20_281, 20, 20, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 20, - target_day_key: 20_281, - granted_points_delta: 20, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_279, 20_281, 20_281, 40, 40, 20, 20), - Some(DailyFreeRefundRestorePlan { - restored_points: 20, - target_day_key: 20_281, - granted_points_delta: 20, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_281, 20_281, 20_281, 60, 50, 10, 10), - Some(DailyFreeRefundRestorePlan { - restored_points: 10, - target_day_key: 20_281, - granted_points_delta: 0, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282), - Some(DailyFreeRefreshPlan { - expired_points: 60, - granted_points: 20, - reset: true, - }), - ); - assert_eq!( - resolve_daily_free_refund_restore_plan(20_282, 20_281, 20_281, 20, 20, 20, 20), - None, - ); - } - - #[test] - fn daily_free_reset_boundary_is_beijing_midnight() { - let day_key = 20_280; - let resets_at_micros = profile_daily_free_points_resets_at_micros(day_key); - - assert_eq!(PROFILE_DAILY_FREE_POINTS_PER_DAY, 20); - assert_eq!( - runtime_profile_beijing_day_key(resets_at_micros.saturating_sub(1)), - day_key, - ); - assert_eq!( - runtime_profile_beijing_day_key(resets_at_micros), - day_key + 1, - ); - } - - #[test] - fn daily_free_refresh_plan_grants_once_and_replaces_cross_day_remainder() { - assert_eq!( - resolve_daily_free_refresh_plan(None, 0, 20_280), - Some(DailyFreeRefreshPlan { - expired_points: 0, - granted_points: 20, - reset: false, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280), - None, - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281), - Some(DailyFreeRefreshPlan { - expired_points: 7, - granted_points: 20, - reset: true, - }), - ); - assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280), - None, - ); - } - - #[test] - fn legacy_wallet_snapshot_keeps_current_daily_free_points() { - assert_eq!( - merge_legacy_wallet_balance_with_daily_free_points(100, 20), - 120, - ); - assert_eq!( - merge_legacy_wallet_balance_with_daily_free_points(100, 0), - 100, - ); - assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, - )); - assert!(!profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::DailyFreeReset, - )); - assert!(profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - )); - } - - #[test] - fn membership_refund_restore_candidate_requires_negative_cycle_delta() { - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata( - r#"{"membershipPeriodPointsDelta":0,"cycleResetsAtMicros":123}"#, - ), - MembershipCyclePointMutation::none(), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata( - r#"{"membershipPeriodPointsDelta":50,"cycleResetsAtMicros":123}"#, - ), - MembershipCyclePointMutation::none(), - ); - assert_eq!( - membership_refund_restore_candidate_from_consume_metadata("not-json"), - MembershipCyclePointMutation::none(), - ); - } - - #[test] - fn membership_upgrade_amount_only_charges_price_difference() { - assert_eq!(membership_upgrade_amount_cents(6_990, 1_990), 5_000); - assert_eq!(membership_upgrade_amount_cents(19_990, 6_990), 13_000); - } - - #[test] - fn legacy_membership_tiers_share_new_pricing_ranks() { - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Month), - RuntimeProfileMembershipTier::Starter, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Season), - RuntimeProfileMembershipTier::Basic, - ); - assert_eq!( - canonical_membership_pricing_tier(RuntimeProfileMembershipTier::Year), - RuntimeProfileMembershipTier::Pro, - ); - } - - #[test] - fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() { - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Starter, - ), - Ok(ActiveMembershipPurchaseMode::Renew), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Month, - RuntimeProfileMembershipTier::Basic, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Season, - RuntimeProfileMembershipTier::Pro, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert_eq!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Ultimate, - ), - Ok(ActiveMembershipPurchaseMode::Upgrade), - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Starter, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Year, - RuntimeProfileMembershipTier::Basic, - ) - .is_err() - ); - assert!( - resolve_active_membership_purchase_mode( - RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Season, - ) - .is_err() - ); - } - - #[test] - fn legacy_membership_migration_renews_and_preserves_cycle_timing() { - let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS); - let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS); - let current_reset_at = - Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS); - let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS); - let row = apply_active_membership_renew_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Month, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Starter, - 30, - purchased_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter); - assert_eq!( - row.expires_at, - Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS), - ); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 90); - assert_eq!(row.cycle_granted_points, 0); - assert_eq!(row.cycle_remaining_points, 0); - assert_eq!(row.updated_at, purchased_at); - } - - #[test] - fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() { - let started_at = Timestamp::from_micros_since_unix_epoch(1_000); - let expires_at = Timestamp::from_micros_since_unix_epoch(90_000); - let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000); - let updated_at = Timestamp::from_micros_since_unix_epoch(2_000); - let (row, period_points_delta) = apply_active_membership_upgrade_row( - ProfileMembership { - user_id: "user-1".to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier: RuntimeProfileMembershipTier::Basic, - started_at, - expires_at, - updated_at: started_at, - cycle_started_at: Some(started_at), - cycle_resets_at: Some(current_reset_at), - cycle_granted_points: 800, - cycle_remaining_points: 120, - cycle_period_days: 90, - }, - RuntimeProfileMembershipTier::Pro, - 7, - 2_500, - updated_at, - ); - - assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro); - assert_eq!(row.expires_at, expires_at); - assert_eq!(row.cycle_resets_at, Some(current_reset_at)); - assert_eq!(row.cycle_period_days, 7); - assert_eq!(row.cycle_granted_points, 2_500); - assert_eq!(row.cycle_remaining_points, 1_820); - assert_eq!(row.updated_at, updated_at); - assert_eq!(period_points_delta, 1_700); - } - - #[test] - fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { - assert!(should_skip_existing_tracking_event_id(true)); - assert!(!should_skip_existing_tracking_event_id(false)); - } - - #[test] - fn tracking_batch_result_reports_accepted_count() { - let result = RuntimeTrackingEventBatchProcedureResult { - ok: true, - accepted_count: 2, - error_message: None, - }; - - assert!(result.ok); - assert_eq!(result.accepted_count, 2); - assert!(result.error_message.is_none()); - } - - #[test] - fn invite_code_metadata_tags_are_user_tags() { - let tags = profile_invite_code_metadata_user_tags( - r#"{"tags":["系统"," beta ","系统"],"channel":"spring"}"#, - ) - .expect("metadata tags should parse"); - - assert_eq!(tags, vec!["系统".to_string(), "beta".to_string()]); - } - - #[test] - fn recent_public_work_play_counts_group_requested_profiles_in_window() { - let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; - let updated_at = Timestamp::from_micros_since_unix_epoch(now_micros); - let rows = vec![ - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:10".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 10, - play_count: 3, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:4".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 4, - play_count: 5, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-a:3".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 3, - play_count: 99, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "custom-world:profile-a:10".to_string(), - source_type: "custom-world".to_string(), - owner_user_id: "user-a".to_string(), - profile_id: "profile-a".to_string(), - played_day: 10, - play_count: 7, - updated_at, - }, - PublicWorkPlayDailyStat { - stat_id: "puzzle:profile-b:9".to_string(), - source_type: "puzzle".to_string(), - owner_user_id: "user-b".to_string(), - profile_id: "profile-b".to_string(), - played_day: 9, - play_count: 11, - updated_at, - }, - ]; - - let counts = build_recent_public_work_play_counts( - rows, - "puzzle", - &["profile-a".to_string(), "profile-b".to_string()], - now_micros, - ); - - assert_eq!(counts.get("profile-a"), Some(&8)); - assert_eq!(counts.get("profile-b"), Some(&11)); - assert_eq!(counts.get("profile-c"), None); - } -} - -fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { - if ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .is_some() - { - return; - } - - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: 0, - total_play_time_ms: 0, - created_at: updated_at, - updated_at, - }); -} - -fn add_profile_dashboard_play_time( - ctx: &ReducerContext, - user_id: &str, - elapsed_ms: u64, - updated_at: Timestamp, -) { - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: existing.wallet_balance, - total_play_time_ms: existing.total_play_time_ms.saturating_add(elapsed_ms), - created_at: existing.created_at, - updated_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: 0, - total_play_time_ms: elapsed_ms, - created_at: updated_at, - updated_at, - }); - } -} - -fn sync_profile_dashboard_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: Option<&serde_json::Map>, - saved_at: Timestamp, -) { - refresh_profile_daily_free_points(ctx, &snapshot.user_id, ctx.timestamp); - let current_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&snapshot.user_id); - let previous_wallet_balance = current_state - .as_ref() - .map(|row| row.wallet_balance) - .unwrap_or(0); - let previous_total_play_time_ms = current_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0); - let daily_free_remaining_points = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&snapshot.user_id) - .map(|row| row.remaining_points) - .unwrap_or(0); - let has_business_wallet_ledger = has_profile_business_wallet_ledger(ctx, &snapshot.user_id); - let synced_wallet_balance = if has_business_wallet_ledger { - None - } else { - game_state - .and_then(|state| state.get("playerCurrency")) - .map(|value| module_runtime::read_runtime_json_non_negative_u64(Some(value))) - .map(|legacy_balance| { - merge_legacy_wallet_balance_with_daily_free_points( - legacy_balance, - daily_free_remaining_points, - ) - }) - }; - let next_wallet_balance = synced_wallet_balance.unwrap_or(previous_wallet_balance); - let mut next_total_play_time_ms = previous_total_play_time_ms; - - if let Some(next_wallet_balance) = synced_wallet_balance - && next_wallet_balance != previous_wallet_balance - { - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id( - &snapshot.user_id, - snapshot.saved_at_micros, - next_wallet_balance, - ), - user_id: snapshot.user_id.clone(), - amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64, - balance_after: next_wallet_balance, - source_type: RuntimeProfileWalletLedgerSourceType::SnapshotSync, - created_at: saved_at, - metadata_json: Some(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), - }); - } - - if let Some(world_meta) = - module_runtime::resolve_runtime_profile_world_snapshot_meta(game_state) - { - let current_play_time_ms = module_runtime::read_runtime_json_non_negative_u64( - game_state - .and_then(|state| state.get("runtimeStats")) - .and_then(JsonValue::as_object) - .and_then(|stats| stats.get("playTimeMs")), - ); - let played_world_id = - build_runtime_profile_played_world_id(&snapshot.user_id, &world_meta.world_key); - let existing = ctx - .db - .profile_played_world() - .played_world_id() - .find(&played_world_id); - let previous_observed_play_time_ms = existing - .as_ref() - .map(|row| row.last_observed_play_time_ms) - .unwrap_or(0); - let incremental_play_time_ms = - current_play_time_ms.saturating_sub(previous_observed_play_time_ms); - next_total_play_time_ms = next_total_play_time_ms.saturating_add(incremental_play_time_ms); - - if let Some(existing) = existing { - ctx.db - .profile_played_world() - .played_world_id() - .delete(&existing.played_world_id); - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: existing.first_played_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms - .max(existing.last_observed_play_time_ms), - }); - } else { - ctx.db.profile_played_world().insert(ProfilePlayedWorld { - played_world_id, - user_id: snapshot.user_id.clone(), - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_title: world_meta.world_title, - world_subtitle: world_meta.world_subtitle, - first_played_at: saved_at, - last_played_at: saved_at, - last_observed_play_time_ms: current_play_time_ms, - }); - } - } - - if let Some(existing) = current_state { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: existing.created_at, - updated_at: saved_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: snapshot.user_id.clone(), - wallet_balance: next_wallet_balance, - total_play_time_ms: next_total_play_time_ms, - created_at: saved_at, - updated_at: saved_at, - }); - } -} - -fn sync_profile_save_archive_from_snapshot( - ctx: &ReducerContext, - snapshot: &RuntimeSnapshot, - game_state: &JsonValue, - saved_at: Timestamp, -) -> Result<(), String> { - let Some(archive_meta) = module_runtime::resolve_runtime_profile_save_archive_meta( - game_state, - snapshot.current_story_json.as_deref(), - ) else { - return Ok(()); - }; - - let archive_id = - build_runtime_profile_save_archive_id(&snapshot.user_id, &archive_meta.world_key); - let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(saved_at); - - if let Some(existing) = existing { - ctx.db - .profile_save_archive() - .archive_id() - .delete(&existing.archive_id); - } - - ctx.db.profile_save_archive().insert(ProfileSaveArchive { - archive_id, - user_id: snapshot.user_id.clone(), - world_key: archive_meta.world_key, - owner_user_id: archive_meta.owner_user_id, - profile_id: archive_meta.profile_id, - world_type: archive_meta.world_type, - world_name: archive_meta.world_name, - subtitle: archive_meta.subtitle, - summary_text: archive_meta.summary_text, - cover_image_src: archive_meta.cover_image_src, - saved_at, - bottom_tab: snapshot.bottom_tab.clone(), - game_state_json: snapshot.game_state_json.clone(), - current_story_json: snapshot.current_story_json.clone(), - created_at, - updated_at: saved_at, - }); - - Ok(()) -} - -pub(crate) fn build_profile_save_archive_snapshot_from_row( - row: &ProfileSaveArchive, -) -> RuntimeProfileSaveArchiveSnapshot { - RuntimeProfileSaveArchiveSnapshot { - archive_id: row.archive_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_name: row.world_name.clone(), - subtitle: row.subtitle.clone(), - summary_text: row.summary_text.clone(), - cover_image_src: row.cover_image_src.clone(), - saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), - bottom_tab: row.bottom_tab.clone(), - game_state_json: row.game_state_json.clone(), - current_story_json: row.current_story_json.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn get_profile_dashboard_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileDashboardGetInput, -) -> Result { - let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) - .map_err(|error| error.to_string())?; - refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); - let state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let played_world_count = ctx - .db - .profile_played_world() - .by_profile_played_world_user_id() - .filter(&validated_input.user_id) - .count() as u32; - let daily_free_points = - build_profile_daily_free_points_snapshot(ctx, &validated_input.user_id, ctx.timestamp); - - Ok(match state { - Some(existing) => RuntimeProfileDashboardSnapshot { - user_id: existing.user_id, - wallet_balance: existing.wallet_balance, - total_play_time_ms: existing.total_play_time_ms, - played_world_count, - updated_at_micros: Some(existing.updated_at.to_micros_since_unix_epoch()), - daily_free_points, - }, - None => RuntimeProfileDashboardSnapshot { - user_id: validated_input.user_id, - wallet_balance: 0, - total_play_time_ms: 0, - played_world_count, - updated_at_micros: None, - daily_free_points, - }, - }) -} - -fn list_profile_wallet_ledger_entries( - ctx: &ReducerContext, - input: RuntimeProfileWalletLedgerListInput, -) -> Result, String> { - let validated_input = build_runtime_profile_wallet_ledger_list_input(input.user_id) - .map_err(|error| error.to_string())?; - refresh_profile_wallet_expiring_points(ctx, &validated_input.user_id, ctx.timestamp); - - let mut entries = ctx - .db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_wallet_ledger_snapshot_from_row(&row)) - .collect::>(); - - let current_balance = profile_wallet_balance(ctx, &validated_input.user_id); - sort_profile_wallet_ledger_entries(&mut entries, current_balance); - entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); - - Ok(entries) -} - -fn sort_profile_wallet_ledger_entries( - entries: &mut Vec, - current_balance: u64, -) { - entries.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| left.wallet_ledger_id.cmp(&right.wallet_ledger_id)) - }); - - let mut positions_by_balance = HashMap::>::new(); - for (position, entry) in entries.iter().enumerate() { - positions_by_balance - .entry(entry.balance_after) - .or_default() - .push_back(position); - } - - let mut remaining = std::mem::take(entries) - .into_iter() - .map(Some) - .collect::>(); - let mut ordered = Vec::with_capacity(remaining.len()); - let mut expected_balance = current_balance; - - while ordered.len() < remaining.len() { - let Some(position) = positions_by_balance - .get_mut(&expected_balance) - .and_then(VecDeque::pop_front) - else { - break; - }; - let Some(entry) = remaining[position].take() else { - break; - }; - let previous_balance = i128::from(entry.balance_after) - i128::from(entry.amount_delta); - let Ok(previous_balance) = u64::try_from(previous_balance) else { - ordered.push(entry); - break; - }; - ordered.push(entry); - expected_balance = previous_balance; - } - - ordered.extend(remaining.into_iter().flatten()); - *entries = ordered; -} - -fn get_profile_play_stats_snapshot( - ctx: &ReducerContext, - input: RuntimeProfilePlayStatsGetInput, -) -> Result { - let validated_input = build_runtime_profile_play_stats_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let dashboard_state = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&validated_input.user_id); - let mut played_works = ctx - .db - .profile_played_world() - .by_profile_played_world_user_id() - .filter(&validated_input.user_id) - .map(|row| build_profile_played_world_snapshot_from_row(&row)) - .collect::>(); - - played_works.sort_by(|left, right| { - right - .last_played_at_micros - .cmp(&left.last_played_at_micros) - .then_with(|| left.played_world_id.cmp(&right.played_world_id)) - }); - - Ok(RuntimeProfilePlayStatsSnapshot { - user_id: validated_input.user_id, - total_play_time_ms: dashboard_state - .as_ref() - .map(|row| row.total_play_time_ms) - .unwrap_or(0), - played_works, - updated_at_micros: dashboard_state - .as_ref() - .map(|row| row.updated_at.to_micros_since_unix_epoch()), - }) -} - -fn get_profile_recharge_center_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileRechargeCenterGetInput, -) -> Result { - let validated_input = build_runtime_profile_recharge_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_recharge_center_snapshot( - ctx, - &validated_input.user_id, - )) -} - -fn create_profile_recharge_order_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderCreateInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_create_input( - input.user_id, - input.product_id, - input.payment_channel, - input.created_at_micros, - ) - .map_err(|error| error.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; - let amount_cents = resolve_profile_recharge_order_amount_cents( - ctx, - &validated_input.user_id, - &product, - created_at, - )?; - let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately { - let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( - ctx, - &validated_input.user_id, - &product, - validated_input.created_at_micros, - created_at, - )?; - ( - RuntimeProfileRechargeOrderStatus::Paid, - Some(created_at), - points_delta, - membership_expires_at, - ) - } else { - (RuntimeProfileRechargeOrderStatus::Pending, None, 0, None) - }; - - let order = ProfileRechargeOrder { - order_id: build_runtime_profile_recharge_order_id( - &validated_input.user_id, - validated_input.created_at_micros, - &product.product_id, - ), - user_id: validated_input.user_id.clone(), - product_id: product.product_id.clone(), - product_title: product.title.clone(), - kind: product.kind, - amount_cents, - status, - payment_channel: validated_input.payment_channel, - paid_at, - provider_transaction_id: None, - created_at, - points_delta, - membership_expires_at, - expired_at: None, - expiration_checked_at: None, - expiration_provider_state: None, - expiration_last_error: None, - }; - ctx.db.profile_recharge_order().insert(order.clone()); - if order.status == RuntimeProfileRechargeOrderStatus::Pending - && should_schedule_profile_recharge_order_expiration(&order.payment_channel) - { - let scheduled_at = created_at - + std::time::Duration::from_secs(PROFILE_RECHARGE_ORDER_EXPIRATION_SECONDS as u64); - ctx.db.profile_recharge_order_expiration_timer().insert( - ProfileRechargeOrderExpirationTimer { - scheduled_id: 0, - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - scheduled_at: scheduled_at.into(), - created_at, - }, - ); - } - - let latest_order = latest_profile_recharge_order(ctx, &validated_input.user_id) - .ok_or_else(|| "profile_recharge_order 写入后未能读取".to_string())?; - Ok(( - build_profile_recharge_center_snapshot(ctx, &validated_input.user_id), - build_profile_recharge_order_snapshot_from_row(&latest_order), - )) -} - -fn get_profile_recharge_order_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderGetInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_get_input(input.order_id) - .map_err(|error| error.to_string())?; - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order missing".to_string())?; - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn mark_profile_recharge_order_paid_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderPaidInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = build_runtime_profile_recharge_order_paid_input( - input.order_id, - input.paid_at_micros, - input.provider_transaction_id, - ) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; - - if matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded - ) { - validate_profile_recharge_order_paid_replay_transaction_id( - &order, - &validated_input.provider_transaction_id, - )?; - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - return Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )); - } - if !matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired - ) { - return Err("profile_recharge_order 当前状态不能确认支付".to_string()); - } - - 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( - ctx, - &order.user_id, - &product, - order.created_at.to_micros_since_unix_epoch(), - paid_at, - )?; - - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Paid; - order.paid_at = Some(paid_at); - order.provider_transaction_id = validated_input.provider_transaction_id; - order.points_delta = points_delta; - order.membership_expires_at = membership_expires_at; - ctx.db.profile_recharge_order().insert(order.clone()); - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn validate_profile_recharge_order_paid_replay_transaction_id( - order: &ProfileRechargeOrder, - provider_transaction_id: &Option, -) -> Result<(), String> { - if &order.provider_transaction_id != provider_transaction_id { - return Err("profile_recharge_order provider_transaction_id 重放不匹配".to_string()); - } - Ok(()) -} - -fn admin_list_profile_recharge_order_entries( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderAdminListInput, -) -> Result, String> { - let validated = build_runtime_profile_recharge_order_admin_list_input( - input.order_id, - input.user_id, - input.provider_transaction_id, - input.payment_channel, - input.status, - input.created_after_micros, - input.created_before_micros, - input.limit, - )?; - let mut rows = ctx - .db - .profile_recharge_order() - .iter() - .filter(|row| profile_recharge_order_matches_admin_query(row, &validated)) - .collect::>(); - rows.sort_by(|left, right| { - right - .created_at - .to_micros_since_unix_epoch() - .cmp(&left.created_at.to_micros_since_unix_epoch()) - .then_with(|| right.order_id.cmp(&left.order_id)) - }); - Ok(rows - .into_iter() - .take(validated.limit as usize) - .map(|row| build_profile_recharge_order_admin_entry_snapshot(ctx, &row)) - .collect()) -} - -fn profile_recharge_order_matches_admin_query( - row: &ProfileRechargeOrder, - query: &RuntimeProfileRechargeOrderAdminListInput, -) -> bool { - query - .order_id - .as_deref() - .is_none_or(|value| row.order_id == value) - && query - .user_id - .as_deref() - .is_none_or(|value| row.user_id == value) - && query - .provider_transaction_id - .as_deref() - .is_none_or(|value| row.provider_transaction_id.as_deref() == Some(value)) - && query - .payment_channel - .as_deref() - .is_none_or(|value| row.payment_channel == value) - && query.status.is_none_or(|value| row.status == value) - && query - .created_after_micros - .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() >= value) - && query - .created_before_micros - .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() <= value) -} - -fn build_profile_recharge_order_admin_entry_snapshot( - ctx: &ReducerContext, - row: &ProfileRechargeOrder, -) -> RuntimeProfileRechargeOrderAdminEntrySnapshot { - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - let mut refunds = ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_order_id() - .filter(&row.order_id) - .map(|value| build_profile_recharge_refund_snapshot_from_row(&value)) - .collect::>(); - refunds.sort_by(|left, right| { - right - .first_observed_at_micros - .cmp(&left.first_observed_at_micros) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - RuntimeProfileRechargeOrderAdminEntrySnapshot { - order: build_profile_recharge_order_snapshot_from_row(row), - settlement, - refunds, - active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id) - .map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)), - wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id), - } -} - -fn preview_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldPreviewInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_preview_input( - input.order_id, - input.refund_cents, - )?; - let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( - ctx, - &validated.order_id, - validated.refund_cents, - )?; - let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - Ok(( - RuntimeProfileRechargeRefundHoldSnapshot { - out_refund_no: String::new(), - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - refund_cents: validated.refund_cents, - held_points, - status: RuntimeProfileRechargeRefundHoldStatus::Active, - admin_user_id: String::new(), - reason: String::new(), - created_at_micros: now_micros, - updated_at_micros: now_micros, - settled_at_micros: None, - released_at_micros: None, - released_by_admin_user_id: None, - release_reason: None, - }, - build_profile_recharge_order_snapshot_from_row(&order), - settlement - .as_ref() - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn prepare_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldPrepareInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_prepare_input( - input.order_id, - input.out_refund_no, - input.refund_cents, - input.admin_user_id, - input.reason, - )?; - if let Some(existing) = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&validated.out_refund_no) - { - if existing.order_id != validated.order_id - || existing.refund_cents != validated.refund_cents - || existing.admin_user_id != validated.admin_user_id - || existing.reason != validated.reason - { - return Err("refund_hold 幂等重放内容冲突".to_string()); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&existing.order_id) - .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - return Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&existing), - build_profile_recharge_order_snapshot_from_row(&order), - settlement, - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )); - } - if ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .is_some() - { - return Err("refund_hold 对应退款单已经存在".to_string()); - } - let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( - ctx, - &validated.order_id, - validated.refund_cents, - )?; - let row = ProfileRechargeRefundHold { - out_refund_no: validated.out_refund_no, - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - refund_cents: validated.refund_cents, - held_points, - status: RuntimeProfileRechargeRefundHoldStatus::Active, - admin_user_id: validated.admin_user_id, - reason: validated.reason, - created_at: ctx.timestamp, - updated_at: ctx.timestamp, - settled_at: None, - released_at: None, - released_by_admin_user_id: None, - release_reason: None, - }; - ctx.db.profile_recharge_refund_hold().insert(row.clone()); - Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&row), - build_profile_recharge_order_snapshot_from_row(&order), - settlement - .as_ref() - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn validate_profile_recharge_refund_hold_eligibility( - ctx: &ReducerContext, - order_id: &str, - refund_cents: u64, -) -> Result< - ( - ProfileRechargeOrder, - Option, - u64, - ), - String, -> { - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&order_id.to_string()) - .ok_or_else(|| "充值订单不存在".to_string())?; - if order.kind != RuntimeProfileRechargeProductKind::Points { - return Err("首期后台退款只支持泥点充值订单".to_string()); - } - if order.status != RuntimeProfileRechargeOrderStatus::Paid || order.paid_at.is_none() { - return Err("充值订单不是可退款的已支付状态".to_string()); - } - if !matches!( - order.payment_channel.as_str(), - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) { - return Err("首期后台退款只支持普通微信 V3 订单".to_string()); - } - if order.provider_transaction_id.is_none() || order.points_delta <= 0 { - return Err("充值订单缺少可退款的支付或泥点结算事实".to_string()); - } - if active_profile_recharge_refund_hold_for_order(ctx, &order.order_id).is_some() { - return Err("充值订单已有退款占用,需先完成对账".to_string()); - } - if ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_order_id() - .filter(&order.order_id) - .any(|row| { - matches!( - row.provider_status, - RuntimeProfileRechargeRefundStatus::Processing - | RuntimeProfileRechargeRefundStatus::Abnormal - ) - }) - { - return Err("充值订单存在未完成退款,需先完成对账".to_string()); - } - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id); - if settlement - .as_ref() - .is_some_and(|value| value.unrecovered_points > 0) - { - return Err("账户存在退款异常欠账,不能继续发起退款".to_string()); - } - if has_profile_wallet_manual_restriction(ctx, &order.user_id) { - return Err("账户已被人工冻结,不能继续发起退款".to_string()); - } - let plan = build_runtime_profile_recharge_refund_settlement_plan( - settlement - .as_ref() - .map(|value| value.successful_refund_count) - .unwrap_or(0), - settlement - .as_ref() - .map(|value| value.cumulative_success_refund_cents) - .unwrap_or(0), - settlement - .as_ref() - .map(|value| value.target_recovery_points) - .unwrap_or(0), - refund_cents, - order.amount_cents, - order.points_delta, - )?; - let remaining_refundable_cents = order.amount_cents.saturating_sub( - settlement - .as_ref() - .map(|value| value.cumulative_success_refund_cents) - .unwrap_or(0), - ); - let held_points = resolve_runtime_profile_recharge_refund_hold_points( - plan.incremental_target_recovery_points, - refund_cents, - remaining_refundable_cents, - ); - let wallet = build_profile_admin_wallet_snapshot(ctx, &order.user_id); - validate_runtime_profile_recharge_refund_hold_capacity( - held_points, - wallet.permanent_points, - wallet.held_points, - )?; - Ok((order, settlement, held_points)) -} - -fn release_profile_recharge_refund_hold( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundHoldReleaseInput, -) -> Result< - ( - RuntimeProfileRechargeRefundHoldSnapshot, - RuntimeProfileRechargeOrderSnapshot, - Option, - RuntimeProfileAdminWalletSnapshot, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_hold_release_input( - input.out_refund_no, - input.admin_user_id, - input.release_reason, - )?; - let mut row = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "refund_hold 不存在".to_string())?; - if row.status == RuntimeProfileRechargeRefundHoldStatus::Settled { - return Err("已结算退款占用不能释放".to_string()); - } - if row.status == RuntimeProfileRechargeRefundHoldStatus::Active { - row.status = RuntimeProfileRechargeRefundHoldStatus::Released; - row.updated_at = ctx.timestamp; - row.released_at = Some(ctx.timestamp); - row.released_by_admin_user_id = Some(validated.admin_user_id); - row.release_reason = Some(validated.release_reason); - upsert_profile_recharge_refund_hold_row(ctx, row.clone()); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&row.order_id) - .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); - Ok(( - build_profile_recharge_refund_hold_snapshot_from_row(&row), - build_profile_recharge_order_snapshot_from_row(&order), - settlement, - build_profile_admin_wallet_snapshot(ctx, &order.user_id), - )) -} - -fn resolve_profile_recharge_refund_manual_review( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundManualReviewResolveInput, -) -> Result< - ( - RuntimeProfileRechargeRefundSnapshot, - Option, - bool, - String, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_manual_review_resolve_input( - input.out_refund_no, - input.admin_user_id, - input.reason, - input.expected_error_code, - )?; - let mut refund = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "退款记录不存在".to_string())?; - validate_profile_recharge_refund_manual_review_request( - &refund, - &validated.expected_error_code, - &validated.admin_user_id, - &validated.reason, - )?; - if refund.manual_review_resolved_at.is_some() { - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&refund.order_id) - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement, - true, - "manual_review_already_resolved".to_string(), - )); - } - if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success - || refund.recovery_status != RuntimeProfileRechargeRefundRecoveryStatus::ManualReview - { - return Err("只有已成功且待人工复核的退款可以确认归属".to_string()); - } - let review_code = refund - .last_error_code - .clone() - .ok_or_else(|| "退款缺少人工复核原因".to_string())?; - if !matches!( - review_code.as_str(), - "provider_transaction_id_mismatch" | "order_total_mismatch" - ) { - return Err(format!( - "当前人工复核原因不能通过确认订单归属收口: {review_code}" - )); - } - let order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&refund.order_id) - .ok_or_else(|| "退款对应充值订单不存在".to_string())?; - if order.kind != RuntimeProfileRechargeProductKind::Points { - return Err("会员退款不能使用泥点钱包人工复核入口".to_string()); - } - if let Some(remaining_code) = profile_recharge_refund_order_match_error_except( - &order, - &refund, - Some(review_code.as_str()), - ) { - return Err(format!( - "确认当前退款冲突后仍存在未获批准的订单不匹配: {remaining_code}" - )); - } - - refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id); - refund.manual_review_resolution_reason = Some(validated.reason); - refund.manual_review_resolved_at = Some(ctx.timestamp); - refund.manual_review_resolved_error_code = Some(review_code); - refund.updated_at = ctx.timestamp; - let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); - if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview { - return Err(format!( - "确认订单归属后权益结算仍需人工复核: {}", - refund - .last_error_code - .as_deref() - .unwrap_or(resolution_code.as_str()) - )); - } - upsert_profile_recharge_refund_row(ctx, refund.clone()); - Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - false, - format!("manual_review_resolved:{resolution_code}"), - )) -} - -fn validate_profile_recharge_refund_manual_review_request( - refund: &ProfileRechargeRefund, - expected_error_code: &str, - admin_user_id: &str, - reason: &str, -) -> Result<(), String> { - let current_error_code = if refund.manual_review_resolved_at.is_some() { - refund.manual_review_resolved_error_code.as_deref() - } else { - refund.last_error_code.as_deref() - }; - if current_error_code != Some(expected_error_code) { - return Err(format!( - "退款人工复核状态已变化,当前错误码为 {}", - current_error_code.unwrap_or("-") - )); - } - if refund.manual_review_resolved_at.is_some() - && (refund.manual_review_resolved_by_admin_user_id.as_deref() != Some(admin_user_id) - || refund.manual_review_resolution_reason.as_deref() != Some(reason)) - { - return Err("退款人工复核已由其他审计内容处理".to_string()); - } - Ok(()) -} - -fn build_profile_admin_wallet_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileAdminWalletSnapshot { - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let total_balance = profile_wallet_balance(ctx, user_id); - let daily_free_points = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.remaining_points) - .unwrap_or(0); - let membership_limited_points = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, ctx.timestamp)) - .map(|row| row.cycle_remaining_points) - .unwrap_or(0); - let permanent_points = total_balance - .saturating_sub(daily_free_points) - .saturating_sub(membership_limited_points); - let held_points = active_profile_recharge_refund_hold_points(ctx, user_id); - let (refund_debt_points, refund_debt_frozen) = ctx - .db - .profile_recharge_order_refund_settlement() - .by_profile_recharge_order_refund_settlement_user_id() - .filter(user_id) - .fold((0_u64, false), |(total, frozen), row| { - ( - total.saturating_add(row.unrecovered_points), - frozen || profile_recharge_refund_settlement_freezes_wallet(&row), - ) - }); - let manual_restriction = ctx - .db - .profile_wallet_manual_restriction() - .user_id() - .find(&user_id.to_string()); - let manual_frozen = manual_restriction.as_ref().is_some_and(|row| row.frozen); - let wallet_frozen = manual_frozen || refund_debt_frozen; - RuntimeProfileAdminWalletSnapshot { - user_id: user_id.to_string(), - total_balance, - spendable_balance: if wallet_frozen { - 0 - } else { - total_balance.saturating_sub(held_points) - }, - daily_free_points, - membership_limited_points, - permanent_points, - held_points, - refund_debt_points, - manual_frozen, - refund_debt_frozen, - wallet_frozen, - manual_restriction: manual_restriction - .as_ref() - .map(build_profile_wallet_manual_restriction_snapshot_from_row), - } -} - -fn upsert_profile_wallet_manual_restriction( - ctx: &ReducerContext, - input: RuntimeProfileWalletManualRestrictionUpsertInput, -) { - let existing = ctx - .db - .profile_wallet_manual_restriction() - .user_id() - .find(&input.user_id); - let row = ProfileWalletManualRestriction { - user_id: input.user_id, - frozen: input.frozen, - reason: input.reason, - created_by_admin_user_id: existing - .as_ref() - .map(|value| value.created_by_admin_user_id.clone()) - .unwrap_or_else(|| input.admin_user_id.clone()), - created_at: existing - .as_ref() - .map(|value| value.created_at) - .unwrap_or(ctx.timestamp), - updated_by_admin_user_id: input.admin_user_id, - updated_at: ctx.timestamp, - }; - ctx.db - .profile_wallet_manual_restriction() - .user_id() - .delete(&row.user_id); - ctx.db.profile_wallet_manual_restriction().insert(row); -} - -fn active_profile_recharge_refund_hold_for_order( - ctx: &ReducerContext, - order_id: &str, -) -> Option { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_order_id() - .filter(order_id) - .find(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) -} - -fn active_profile_recharge_refund_hold_points(ctx: &ReducerContext, user_id: &str) -> u64 { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_user_id() - .filter(user_id) - .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) - .fold(0_u64, |total, row| total.saturating_add(row.held_points)) -} - -fn active_profile_recharge_refund_unrelated_hold_points( - ctx: &ReducerContext, - user_id: &str, - refund: &ProfileRechargeRefund, -) -> u64 { - ctx.db - .profile_recharge_refund_hold() - .by_profile_recharge_refund_hold_user_id() - .filter(user_id) - .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) - .filter(|row| { - row.out_refund_no != refund.out_refund_no - || row.order_id != refund.order_id - || row.refund_cents != refund.refund_cents - }) - .fold(0_u64, |total, row| total.saturating_add(row.held_points)) -} - -fn has_profile_wallet_manual_restriction(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_wallet_manual_restriction() - .user_id() - .find(&user_id.to_string()) - .is_some_and(|row| row.frozen) -} - -fn upsert_profile_recharge_refund_hold_row(ctx: &ReducerContext, row: ProfileRechargeRefundHold) { - ctx.db - .profile_recharge_refund_hold() - .out_refund_no() - .delete(&row.out_refund_no); - ctx.db.profile_recharge_refund_hold().insert(row); -} - -fn sync_profile_recharge_refund_hold_with_provider_status( - ctx: &ReducerContext, - refund: &ProfileRechargeRefund, -) { - let Some(mut hold) = ctx - .db - .profile_recharge_refund_hold() - .out_refund_no() - .find(&refund.out_refund_no) - else { - return; - }; - if hold.order_id != refund.order_id || hold.refund_cents != refund.refund_cents { - return; - } - - let next_status = - resolve_runtime_profile_recharge_refund_hold_status(hold.status, refund.provider_status); - if next_status == hold.status { - return; - } - hold.status = next_status; - hold.updated_at = ctx.timestamp; - match next_status { - RuntimeProfileRechargeRefundHoldStatus::Settled => { - hold.settled_at = Some(ctx.timestamp); - } - RuntimeProfileRechargeRefundHoldStatus::Released => { - hold.released_at = Some(ctx.timestamp); - hold.released_by_admin_user_id = None; - hold.release_reason = Some("provider_closed".to_string()); - } - RuntimeProfileRechargeRefundHoldStatus::Active => {} - } - upsert_profile_recharge_refund_hold_row(ctx, hold); -} - -fn record_profile_recharge_refund_observation( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundObservationInput, -) -> Result< - ( - RuntimeProfileRechargeRefundSnapshot, - Option, - bool, - String, - ), - String, -> { - let validated = build_runtime_profile_recharge_refund_observation_input( - input.observation_id, - input.source, - input.notification_ref, - input.payload_fingerprint, - input.out_refund_no, - input.provider_refund_id, - input.order_id, - input.provider_transaction_id, - input.provider_status, - input.total_cents, - input.refund_cents, - input.payer_total_cents, - input.payer_refund_cents, - input.success_at_micros, - input.observed_at_micros, - )?; - let observed_at = Timestamp::from_micros_since_unix_epoch(validated.observed_at_micros); - let success_at = validated - .success_at_micros - .map(Timestamp::from_micros_since_unix_epoch); - - if let Some(existing_observation) = ctx - .db - .profile_recharge_refund_observation() - .observation_id() - .find(&validated.observation_id) - { - validate_profile_recharge_refund_observation_replay(&existing_observation, &validated)?; - let mut refund = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no) - .ok_or_else(|| "退款 observation 已存在但退款单缺失".to_string())?; - if observed_at.to_micros_since_unix_epoch() > refund.updated_at.to_micros_since_unix_epoch() - { - refund.updated_at = observed_at; - refund.last_observation_source = validated.source; - refund.last_observation_id = validated.observation_id.clone(); - } - let (settlement, resolution_code) = - reconcile_profile_recharge_refund_success(ctx, &mut refund); - sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); - upsert_profile_recharge_refund_row(ctx, refund.clone()); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - true, - resolution_code, - )); - } - - if let Some(provider_collision) = ctx - .db - .profile_recharge_refund() - .provider_refund_id() - .find(&validated.provider_refund_id) - .filter(|row| row.out_refund_no != validated.out_refund_no) - { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "provider_refund_id_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&provider_collision.order_id) - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&provider_collision), - settlement, - false, - "provider_refund_id_conflict".to_string(), - )); - } - - let existing = ctx - .db - .profile_recharge_refund() - .out_refund_no() - .find(&validated.out_refund_no); - let mut refund = match existing { - Some(mut row) => { - if !profile_recharge_refund_matches_observation(&row, &validated) { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "immutable_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| { - build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) - }); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - false, - "immutable_conflict".to_string(), - )); - } - - let transition = resolve_runtime_profile_recharge_refund_status_transition( - row.provider_status, - validated.provider_status, - ); - if transition == RuntimeProfileRechargeRefundStatusTransition::Conflict { - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - "status_conflict", - ); - let settlement = ctx - .db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&row.order_id) - .map(|value| { - build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) - }); - return Ok(( - build_profile_recharge_refund_snapshot_from_row(&row), - settlement, - false, - "status_conflict".to_string(), - )); - } - if transition == RuntimeProfileRechargeRefundStatusTransition::Advance { - row.provider_status = validated.provider_status; - row.success_at = success_at; - } - row.updated_at = latest_profile_recharge_refund_timestamp(row.updated_at, observed_at); - row.last_observation_source = validated.source; - row.last_observation_id = validated.observation_id.clone(); - row - } - None => ProfileRechargeRefund { - out_refund_no: validated.out_refund_no.clone(), - provider_refund_id: validated.provider_refund_id.clone(), - order_id: validated.order_id.clone(), - provider_transaction_id: validated.provider_transaction_id.clone(), - user_id: None, - provider_status: validated.provider_status, - total_cents: validated.total_cents, - refund_cents: validated.refund_cents, - payer_total_cents: validated.payer_total_cents, - payer_refund_cents: validated.payer_refund_cents, - success_at, - first_observed_at: observed_at, - updated_at: observed_at, - last_observation_source: validated.source, - last_observation_id: validated.observation_id.clone(), - order_settled_at: None, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: if validated.provider_status - == RuntimeProfileRechargeRefundStatus::Success - { - RuntimeProfileRechargeRefundRecoveryStatus::Pending - } else { - RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable - }, - last_recovery_ledger_id: None, - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - manual_review_resolved_error_code: None, - }, - }; - - let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); - sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); - upsert_profile_recharge_refund_row(ctx, refund.clone()); - insert_profile_recharge_refund_observation( - ctx, - &validated, - observed_at, - success_at, - &resolution_code, - ); - - Ok(( - build_profile_recharge_refund_snapshot_from_row(&refund), - settlement - .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), - false, - resolution_code, - )) -} - -fn reconcile_profile_recharge_refund_success( - ctx: &ReducerContext, - refund: &mut ProfileRechargeRefund, -) -> (Option, String) { - if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable; - refund.last_error_code = None; - return (None, "recorded_non_success".to_string()); - } - - let Some(mut order) = ctx - .db - .profile_recharge_order() - .order_id() - .find(&refund.order_id) - else { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some("order_missing".to_string()); - return (None, "order_missing".to_string()); - }; - refund.user_id = Some(order.user_id.clone()); - - let mismatch_code = unresolved_profile_recharge_refund_order_match_error(&order, refund); - if let Some(code) = mismatch_code { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.clone()); - let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); - settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.wallet_frozen |= - profile_recharge_refund_manual_review_freezes_wallet(order.kind, code.as_str()); - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), code); - } - - let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); - if refund.order_settled_at.is_none() { - let order_points_delta = if order.kind == RuntimeProfileRechargeProductKind::Points { - order.points_delta - } else { - 0 - }; - let plan = match build_runtime_profile_recharge_refund_settlement_plan( - settlement.successful_refund_count, - settlement.cumulative_success_refund_cents, - settlement.target_recovery_points, - refund.refund_cents, - order.amount_cents, - order_points_delta, - ) { - Ok(plan) => plan, - Err(_) => { - return mark_profile_recharge_refund_manual_review( - ctx, - refund, - settlement, - order.kind, - "refund_settlement_plan_invalid", - ); - } - }; - - settlement.successful_refund_count = plan.successful_refund_count; - settlement.cumulative_success_refund_cents = plan.cumulative_success_refund_cents; - refund.order_settled_at = Some(ctx.timestamp); - if plan.order_fully_refunded { - order.status = RuntimeProfileRechargeOrderStatus::Refunded; - upsert_profile_recharge_order_row(ctx, order.clone()); - } - match order.kind { - RuntimeProfileRechargeProductKind::Points => { - refund.target_recovery_points = plan.incremental_target_recovery_points; - refund.unrecovered_points = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - settlement.target_recovery_points = plan.target_recovery_points; - settlement.unrecovered_points = settlement - .target_recovery_points - .saturating_sub(settlement.recovered_points); - } - RuntimeProfileRechargeProductKind::Membership => { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some("membership_manual_review".to_string()); - settlement.recovery_status = - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - } - } - } - - if order.kind == RuntimeProfileRechargeProductKind::Membership { - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), "membership_manual_review".to_string()); - } - - let outstanding = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - if outstanding > 0 { - match apply_profile_recharge_refund_permanent_points_recovery(ctx, refund, outstanding) { - Ok((recovered, ledger_id)) => { - refund.recovered_points = refund.recovered_points.saturating_add(recovered); - refund.unrecovered_points = refund - .target_recovery_points - .saturating_sub(refund.recovered_points); - refund.last_recovery_ledger_id = ledger_id; - settlement.recovered_points = settlement.recovered_points.saturating_add(recovered); - settlement.unrecovered_points = settlement - .target_recovery_points - .saturating_sub(settlement.recovered_points); - } - Err(code) => { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.clone()); - settlement.recovery_status = - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - return (Some(settlement), code); - } - } - } - - if refund.unrecovered_points == 0 { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Applied; - refund.last_error_code = None; - } else { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; - refund.last_error_code = Some("permanent_points_shortfall".to_string()); - } - settlement.recovery_status = if settlement.unrecovered_points == 0 { - RuntimeProfileRechargeRefundRecoveryStatus::Applied - } else { - RuntimeProfileRechargeRefundRecoveryStatus::Shortfall - }; - settlement.wallet_frozen = settlement.unrecovered_points > 0; - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - - let resolution_code = if refund.unrecovered_points == 0 { - "settled" - } else { - "settled_shortfall" - }; - (Some(settlement), resolution_code.to_string()) -} - -#[cfg(test)] -fn validate_profile_recharge_refund_order_match( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, -) -> Result<(), String> { - profile_recharge_refund_order_match_error_except(order, refund, None).map_or(Ok(()), Err) -} - -fn profile_recharge_refund_order_match_error_except( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, - approved_error_code: Option<&str>, -) -> Option { - if !matches!( - order.payment_channel.as_str(), - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) { - return Some("payment_channel_not_v3".to_string()); - } - if order.paid_at.is_none() - || !matches!( - order.status, - RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded - ) - { - return Some("order_not_paid".to_string()); - } - if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) - && approved_error_code != Some("provider_transaction_id_mismatch") - { - return Some("provider_transaction_id_mismatch".to_string()); - } - if order.amount_cents != refund.total_cents - && approved_error_code != Some("order_total_mismatch") - { - return Some("order_total_mismatch".to_string()); - } - None -} - -fn unresolved_profile_recharge_refund_order_match_error( - order: &ProfileRechargeOrder, - refund: &ProfileRechargeRefund, -) -> Option { - let approved_error_code = refund - .manual_review_resolved_at - .is_some() - .then_some(refund.manual_review_resolved_error_code.as_deref()) - .flatten(); - profile_recharge_refund_order_match_error_except(order, refund, approved_error_code) -} - -fn mark_profile_recharge_refund_manual_review( - ctx: &ReducerContext, - refund: &mut ProfileRechargeRefund, - mut settlement: ProfileRechargeOrderRefundSettlement, - order_kind: RuntimeProfileRechargeProductKind, - code: &str, -) -> (Option, String) { - refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - refund.last_error_code = Some(code.to_string()); - settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; - settlement.wallet_frozen |= - profile_recharge_refund_manual_review_freezes_wallet(order_kind, code); - settlement.updated_at = ctx.timestamp; - upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); - (Some(settlement), code.to_string()) -} - -fn profile_recharge_refund_manual_review_freezes_wallet( - order_kind: RuntimeProfileRechargeProductKind, - code: &str, -) -> bool { - order_kind == RuntimeProfileRechargeProductKind::Points - && matches!( - code, - "provider_transaction_id_mismatch" - | "order_total_mismatch" - | "refund_settlement_plan_invalid" - ) -} - -fn apply_profile_recharge_refund_permanent_points_recovery( - ctx: &ReducerContext, - refund: &ProfileRechargeRefund, - outstanding_points: u64, -) -> Result<(u64, Option), String> { - let user_id = refund - .user_id - .as_deref() - .ok_or_else(|| "refund_user_missing".to_string())?; - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let daily_free = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.remaining_points) - .unwrap_or(0); - let membership_limited = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, ctx.timestamp)) - .map(|row| row.cycle_remaining_points) - .unwrap_or(0); - let unrelated_held_points = - active_profile_recharge_refund_unrelated_hold_points(ctx, user_id, refund); - let (recoverable, _) = resolve_runtime_profile_recharge_refund_recovery_with_holds( - outstanding_points, - wallet_total, - daily_free, - membership_limited, - unrelated_held_points, - ); - if recoverable == 0 { - return Ok((0, None)); - } - - let amount_delta = - -i64::try_from(recoverable).map_err(|_| "refund_recovery_points_overflow".to_string())?; - let next_recovered = refund.recovered_points.saturating_add(recoverable); - let ledger_id = format!( - "recharge-refund-recovery:{}:{}", - refund.out_refund_no, next_recovered - ); - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .is_some() - { - return Err("refund_recovery_ledger_conflict".to_string()); - } - let Some(existing) = current else { - return Err("refund_wallet_state_missing".to_string()); - }; - let next_balance = existing - .wallet_balance - .checked_sub(recoverable) - .ok_or_else(|| "refund_wallet_balance_underflow".to_string())?; - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: existing.user_id.clone(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: ctx.timestamp, - }); - let metadata_json = metadata_with_profile_wallet_delta_split( - &json!({ - "rechargeOrderId": refund.order_id, - "outRefundNo": refund.out_refund_no, - "providerRefundId": refund.provider_refund_id, - }) - .to_string(), - 0, - 0, - amount_delta, - None, - None, - ); - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.clone(), - user_id: existing.user_id, - amount_delta, - balance_after: next_balance, - source_type: RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery, - created_at: ctx.timestamp, - metadata_json: Some(metadata_json), - }); - Ok((recoverable, Some(ledger_id))) -} - -fn repay_profile_recharge_refund_debt_from_permanent_points(ctx: &ReducerContext, user_id: &str) { - let mut candidates = ctx - .db - .profile_recharge_refund() - .iter() - .filter(|row| { - row.user_id.as_deref() == Some(user_id) - && row.provider_status == RuntimeProfileRechargeRefundStatus::Success - && row.unrecovered_points > 0 - && matches!( - row.recovery_status, - RuntimeProfileRechargeRefundRecoveryStatus::Pending - | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall - ) - }) - .collect::>(); - candidates.sort_by(|left, right| { - left.first_observed_at - .to_micros_since_unix_epoch() - .cmp(&right.first_observed_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - - for mut refund in candidates { - reconcile_profile_recharge_refund_success(ctx, &mut refund); - upsert_profile_recharge_refund_row(ctx, refund); - } -} - -fn profile_recharge_order_refund_settlement_row( - ctx: &ReducerContext, - order: &ProfileRechargeOrder, -) -> ProfileRechargeOrderRefundSettlement { - ctx.db - .profile_recharge_order_refund_settlement() - .order_id() - .find(&order.order_id) - .unwrap_or_else(|| ProfileRechargeOrderRefundSettlement { - order_id: order.order_id.clone(), - user_id: order.user_id.clone(), - successful_refund_count: 0, - cumulative_success_refund_cents: 0, - target_recovery_points: 0, - recovered_points: 0, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, - wallet_frozen: false, - updated_at: ctx.timestamp, - }) -} - -fn profile_recharge_refund_matches_observation( - row: &ProfileRechargeRefund, - input: &RuntimeProfileRechargeRefundObservationInput, -) -> bool { - row.provider_refund_id == input.provider_refund_id - && row.order_id == input.order_id - && row.provider_transaction_id == input.provider_transaction_id - && row.total_cents == input.total_cents - && row.refund_cents == input.refund_cents - && row.payer_total_cents == input.payer_total_cents - && row.payer_refund_cents == input.payer_refund_cents - && (row.provider_status != RuntimeProfileRechargeRefundStatus::Success - || row - .success_at - .map(|value| value.to_micros_since_unix_epoch()) - == input.success_at_micros) -} - -fn validate_profile_recharge_refund_observation_replay( - row: &ProfileRechargeRefundObservation, - input: &RuntimeProfileRechargeRefundObservationInput, -) -> Result<(), String> { - let success_at_micros = row - .success_at - .map(|value| value.to_micros_since_unix_epoch()); - if row.out_refund_no != input.out_refund_no - || row.provider_refund_id != input.provider_refund_id - || row.order_id != input.order_id - || row.provider_transaction_id != input.provider_transaction_id - || row.source != input.source - || row.provider_status != input.provider_status - || row.total_cents != input.total_cents - || row.refund_cents != input.refund_cents - || row.payer_total_cents != input.payer_total_cents - || row.payer_refund_cents != input.payer_refund_cents - || success_at_micros != input.success_at_micros - || row.notification_ref != input.notification_ref - || row.payload_fingerprint != input.payload_fingerprint - { - return Err("退款 observation_id 重放内容冲突".to_string()); - } - Ok(()) -} - -fn insert_profile_recharge_refund_observation( - ctx: &ReducerContext, - input: &RuntimeProfileRechargeRefundObservationInput, - observed_at: Timestamp, - success_at: Option, - resolution_code: &str, -) { - ctx.db - .profile_recharge_refund_observation() - .insert(ProfileRechargeRefundObservation { - observation_id: input.observation_id.clone(), - out_refund_no: input.out_refund_no.clone(), - provider_refund_id: input.provider_refund_id.clone(), - order_id: input.order_id.clone(), - provider_transaction_id: input.provider_transaction_id.clone(), - source: input.source, - provider_status: input.provider_status, - total_cents: input.total_cents, - refund_cents: input.refund_cents, - payer_total_cents: input.payer_total_cents, - payer_refund_cents: input.payer_refund_cents, - success_at, - notification_ref: input.notification_ref.clone(), - payload_fingerprint: input.payload_fingerprint.clone(), - resolution_code: resolution_code.to_string(), - observed_at, - }); -} - -fn upsert_profile_recharge_refund_row(ctx: &ReducerContext, row: ProfileRechargeRefund) { - ctx.db - .profile_recharge_refund() - .out_refund_no() - .delete(&row.out_refund_no); - ctx.db.profile_recharge_refund().insert(row); -} - -fn upsert_profile_recharge_order_refund_settlement_row( - ctx: &ReducerContext, - row: ProfileRechargeOrderRefundSettlement, -) { - ctx.db - .profile_recharge_order_refund_settlement() - .order_id() - .delete(&row.order_id); - ctx.db - .profile_recharge_order_refund_settlement() - .insert(row); -} - -fn upsert_profile_recharge_order_row(ctx: &ReducerContext, row: ProfileRechargeOrder) { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&row.order_id); - ctx.db.profile_recharge_order().insert(row); -} - -fn latest_profile_recharge_refund_timestamp(current: Timestamp, observed: Timestamp) -> Timestamp { - if observed.to_micros_since_unix_epoch() > current.to_micros_since_unix_epoch() { - observed - } else { - current - } -} - -fn list_profile_recharge_refund_reconciliation_rows( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundReconciliationListInput, -) -> Vec { - let validated = build_runtime_profile_recharge_refund_reconciliation_list_input(input.limit); - let mut candidates = HashMap::::new(); - for status in [ - RuntimeProfileRechargeRefundStatus::Processing, - RuntimeProfileRechargeRefundStatus::Abnormal, - RuntimeProfileRechargeRefundStatus::Success, - ] { - for row in ctx - .db - .profile_recharge_refund() - .by_profile_recharge_refund_status_updated_at() - .filter(status) - { - let needs_reconciliation = profile_recharge_refund_needs_reconciliation(&row); - if needs_reconciliation { - candidates.insert(row.out_refund_no.clone(), row); - } - } - } - let mut rows = candidates.into_values().collect::>(); - rows.sort_by(|left, right| { - left.updated_at - .to_micros_since_unix_epoch() - .cmp(&right.updated_at.to_micros_since_unix_epoch()) - .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) - }); - let rotation_slot = ctx - .timestamp - .to_micros_since_unix_epoch() - .div_euclid(60 * 1_000_000) - .unsigned_abs(); - select_profile_recharge_refund_reconciliation_page( - rows, - validated.limit as usize, - rotation_slot, - ) - .into_iter() - .map(|row| build_profile_recharge_refund_snapshot_from_row(&row)) - .collect() -} - -fn profile_recharge_refund_needs_reconciliation(row: &ProfileRechargeRefund) -> bool { - if row.provider_status != RuntimeProfileRechargeRefundStatus::Success { - return row.provider_status != RuntimeProfileRechargeRefundStatus::Closed; - } - match row.recovery_status { - RuntimeProfileRechargeRefundRecoveryStatus::Pending - | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall => true, - RuntimeProfileRechargeRefundRecoveryStatus::ManualReview => matches!( - row.last_error_code.as_deref(), - Some("order_missing" | "order_not_paid") - ), - RuntimeProfileRechargeRefundRecoveryStatus::Applied - | RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable => false, - } -} - -fn select_profile_recharge_refund_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_profile_recharge_refund_hold_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - select_rotating_reconciliation_page(rows, limit, rotation_slot) -} - -fn select_rotating_reconciliation_page( - rows: Vec, - limit: usize, - rotation_slot: u64, -) -> Vec { - if rows.len() <= limit || limit == 0 { - return rows; - } - let page_count = rows.len().div_ceil(limit); - let page_index = usize::try_from(rotation_slot % page_count as u64).unwrap_or(0); - rows.into_iter() - .skip(page_index.saturating_mul(limit)) - .take(limit) - .collect() -} - -fn advance_profile_recharge_refund_bill_checkpoint( - ctx: &ReducerContext, - input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, -) -> Result { - let validated = build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( - input.checkpoint_id, - input.bill_date, - input.bill_hash, - input.processed_refund_count, - input.completed_at_micros, - )?; - let completed_at = Timestamp::from_micros_since_unix_epoch(validated.completed_at_micros); - if let Some(existing) = ctx - .db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .find(&validated.checkpoint_id) - { - if validated.bill_date < existing.bill_date { - return Err("退款账单 checkpoint 不能回退".to_string()); - } - if validated.bill_date == existing.bill_date { - if validated.bill_hash != existing.bill_hash - || validated.processed_refund_count != existing.processed_refund_count - { - return Err("同一退款账单日期的 hash 或退款条数冲突".to_string()); - } - return Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&existing)); - } - ctx.db - .profile_recharge_refund_bill_checkpoint() - .checkpoint_id() - .delete(&existing.checkpoint_id); - } - let row = ProfileRechargeRefundBillCheckpoint { - checkpoint_id: validated.checkpoint_id, - bill_date: validated.bill_date, - bill_hash: validated.bill_hash, - processed_refund_count: validated.processed_refund_count, - completed_at, - updated_at: ctx.timestamp, - }; - ctx.db - .profile_recharge_refund_bill_checkpoint() - .insert(row.clone()); - Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row)) -} - -fn claim_profile_recharge_order_expiration_schedules( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationClaimInput, -) -> Result, String> { - let validated_input = build_runtime_profile_recharge_order_expiration_claim_input( - input.worker_id, - input.now_micros, - input.lease_expires_at_micros, - input.limit, - ) - .map_err(|error| error.to_string())?; - let now = Timestamp::from_micros_since_unix_epoch(validated_input.now_micros); - let lease_expires_at = - Timestamp::from_micros_since_unix_epoch(validated_input.lease_expires_at_micros); - let limit = if validated_input.limit == 0 { - PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_DEFAULT - } else { - validated_input - .limit - .min(PROFILE_RECHARGE_ORDER_EXPIRATION_CLAIM_LIMIT_MAX) - } as usize; - - let mut candidates = ctx - .db - .profile_recharge_order_expiration_schedule() - .iter() - .filter(|row| { - row.scheduled_at <= now - && row - .lease_expires_at - .map(|value| value <= now) - .unwrap_or(true) - }) - .collect::>(); - candidates.sort_by_key(|row| row.scheduled_at.to_micros_since_unix_epoch()); - - let mut claimed = Vec::new(); - for mut schedule in candidates.into_iter().take(limit) { - let should_claim = ctx - .db - .profile_recharge_order() - .order_id() - .find(&schedule.order_id) - .map(|order| order.status == RuntimeProfileRechargeOrderStatus::Pending) - .unwrap_or(false); - if !should_claim { - delete_profile_recharge_order_expiration_schedule(ctx, &schedule.order_id); - continue; - } - - ctx.db - .profile_recharge_order_expiration_schedule() - .order_id() - .delete(&schedule.order_id); - schedule.lease_owner = Some(validated_input.worker_id.clone()); - schedule.lease_expires_at = Some(lease_expires_at); - schedule.updated_at = now; - ctx.db - .profile_recharge_order_expiration_schedule() - .insert(schedule.clone()); - claimed.push(build_profile_recharge_order_expiration_schedule_snapshot_from_row(&schedule)); - } - - Ok(claimed) -} - -fn list_unchecked_expired_profile_recharge_order_rows( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCheckListInput, -) -> Result, String> { - let validated_input = - build_runtime_profile_recharge_order_expiration_check_list_input(input.limit) - .map_err(|error| error.to_string())?; - let limit = if validated_input.limit == 0 { - PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_DEFAULT - } else { - validated_input - .limit - .min(PROFILE_RECHARGE_ORDER_EXPIRATION_CHECK_LIMIT_MAX) - } as usize; - - let mut orders = ctx - .db - .profile_recharge_order() - .iter() - .filter(|row| { - row.status == RuntimeProfileRechargeOrderStatus::Expired - && row.expiration_checked_at.is_none() - }) - .collect::>(); - orders.sort_by(|left, right| { - left.expired_at - .map(|value| value.to_micros_since_unix_epoch()) - .unwrap_or_else(|| left.created_at.to_micros_since_unix_epoch()) - .cmp( - &right - .expired_at - .map(|value| value.to_micros_since_unix_epoch()) - .unwrap_or_else(|| right.created_at.to_micros_since_unix_epoch()), - ) - .then_with(|| left.order_id.cmp(&right.order_id)) - }); - - Ok(orders - .into_iter() - .take(limit) - .map(|row| build_profile_recharge_order_snapshot_from_row(&row)) - .collect()) -} - -fn mark_profile_recharge_order_expiration_checked_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCheckInput, -) -> Result { - let validated_input = build_runtime_profile_recharge_order_expiration_check_input( - input.order_id, - input.checked_at_micros, - input.provider_state, - input.last_error, - ) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order missing".to_string())?; - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.expiration_checked_at = validated_input - .checked_at_micros - .map(Timestamp::from_micros_since_unix_epoch); - order.expiration_provider_state = validated_input.provider_state; - order.expiration_last_error = validated_input.last_error; - ctx.db.profile_recharge_order().insert(order.clone()); - Ok(build_profile_recharge_order_snapshot_from_row(&order)) -} - -fn complete_profile_recharge_order_expiration_schedule( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderExpirationCompleteInput, -) -> Result<(), String> { - let validated_input = - build_runtime_profile_recharge_order_expiration_complete_input(input.order_id) - .map_err(|error| error.to_string())?; - delete_profile_recharge_order_expiration_schedule(ctx, &validated_input.order_id); - Ok(()) -} - -fn close_profile_recharge_order_record( - ctx: &ReducerContext, - input: RuntimeProfileRechargeOrderCloseInput, -) -> Result< - ( - RuntimeProfileRechargeCenterSnapshot, - RuntimeProfileRechargeOrderSnapshot, - ), - String, -> { - let validated_input = - build_runtime_profile_recharge_order_close_input(input.order_id, input.closed_at_micros) - .map_err(|error| error.to_string())?; - let mut order = ctx - .db - .profile_recharge_order() - .order_id() - .find(&validated_input.order_id) - .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; - - if order.status == RuntimeProfileRechargeOrderStatus::Pending { - ctx.db - .profile_recharge_order() - .order_id() - .delete(&order.order_id); - order.status = RuntimeProfileRechargeOrderStatus::Closed; - ctx.db.profile_recharge_order().insert(order.clone()); - } - delete_profile_recharge_order_expiration_task(ctx, &order.order_id); - - Ok(( - build_profile_recharge_center_snapshot(ctx, &order.user_id), - build_profile_recharge_order_snapshot_from_row(&order), - )) -} - -fn should_schedule_profile_recharge_order_expiration(payment_channel: &str) -> bool { - matches!( - payment_channel, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI - | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE - ) -} - -fn delete_profile_recharge_order_expiration_task(ctx: &ReducerContext, order_id: &str) { - delete_profile_recharge_order_expiration_timer(ctx, order_id); - delete_profile_recharge_order_expiration_schedule(ctx, order_id); -} - -fn delete_profile_recharge_order_expiration_timer(ctx: &ReducerContext, order_id: &str) { - let order_id = order_id.to_string(); - if let Some(timer) = ctx - .db - .profile_recharge_order_expiration_timer() - .order_id() - .find(&order_id) - { - ctx.db - .profile_recharge_order_expiration_timer() - .scheduled_id() - .delete(&timer.scheduled_id); - } -} - -fn delete_profile_recharge_order_expiration_schedule(ctx: &ReducerContext, order_id: &str) { - ctx.db - .profile_recharge_order_expiration_schedule() - .order_id() - .delete(&order_id.to_string()); -} - -fn apply_profile_recharge_purchase( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - order_created_at_micros: i64, - paid_at: Timestamp, -) -> Result<(i64, Option), String> { - match product.kind { - RuntimeProfileRechargeProductKind::Points => { - 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( - ctx, - user_id, - points_delta, - RuntimeProfileWalletLedgerSourceType::PointsRecharge, - &build_runtime_profile_recharge_wallet_ledger_id( - user_id, - order_created_at_micros, - &product.product_id, - ), - paid_at, - )?; - Ok((points_delta as i64, None)) - } - RuntimeProfileRechargeProductKind::Membership => { - let purchase_result = - apply_profile_membership_purchase(ctx, user_id, product, paid_at)?; - Ok(( - purchase_result.period_points_delta as i64, - Some(purchase_result.expires_at), - )) - } - } -} - -fn submit_profile_feedback_record( - ctx: &ReducerContext, - input: RuntimeProfileFeedbackSubmissionInput, -) -> Result { - let validated_input = build_runtime_profile_feedback_submission_input( - input.user_id, - input.description, - input.contact_phone, - input.evidence_items, - input.created_at_micros, - ) - .map_err(|error| error.to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let feedback_id = build_runtime_profile_feedback_submission_id( - &validated_input.user_id, - validated_input.created_at_micros, - ); - let evidence_json = serde_json::to_string(&validated_input.evidence_items) - .map_err(|error| format!("反馈凭证序列化失败: {error}"))?; - let row = ProfileFeedbackSubmission { - feedback_id: feedback_id.clone(), - user_id: validated_input.user_id, - description: validated_input.description, - contact_phone: validated_input.contact_phone, - evidence_json, - status: RuntimeProfileFeedbackStatus::Open, - created_at, - updated_at: created_at, - }; - ctx.db.profile_feedback_submission().insert(row); - - let latest = ctx - .db - .profile_feedback_submission() - .feedback_id() - .find(&feedback_id) - .ok_or_else(|| "profile_feedback_submission 写入后未能读取".to_string())?; - - Ok(build_profile_feedback_submission_snapshot_from_row(&latest)) -} - -fn get_profile_referral_invite_center_snapshot( - ctx: &ReducerContext, - input: RuntimeReferralInviteCenterGetInput, -) -> Result { - let validated_input = build_runtime_referral_invite_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_referral_invite_center_snapshot( - ctx, - &validated_input.user_id, - )) -} - -fn redeem_profile_referral_invite_code_record( - ctx: &ReducerContext, - input: RuntimeReferralRedeemInput, -) -> Result { - let validated_input = build_runtime_referral_redeem_input( - input.user_id, - input.invite_code, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let bound_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let invitee_user_id = validated_input.user_id; - let invite_code = validated_input.invite_code; - - if ctx - .db - .user_account() - .user_id() - .find(&invitee_user_id) - .is_none() - { - return Err("用户不存在".to_string()); - } - - if ctx - .db - .profile_referral_relation() - .invitee_user_id() - .find(&invitee_user_id) - .is_some() - { - return Err("每个用户最多只能填写一个邀请码".to_string()); - } - - let inviter_code = ctx - .db - .profile_invite_code() - .invite_code() - .find(&invite_code) - .ok_or_else(|| "邀请码不存在".to_string())?; - validate_profile_invite_code_redeem_time(&inviter_code, validated_input.updated_at_micros)?; - if inviter_code.user_id == invitee_user_id { - return Err("不能填写自己的邀请码".to_string()); - } - let invite_metadata_user_tags = - profile_invite_code_metadata_user_tags(&inviter_code.metadata_json)?; - - let invitee_balance_after = apply_profile_wallet_delta( - ctx, - &invitee_user_id, - PROFILE_REFERRAL_REWARD_POINTS, - RuntimeProfileWalletLedgerSourceType::InviteInviteeReward, - &build_runtime_profile_referral_invitee_ledger_id( - &invitee_user_id, - validated_input.updated_at_micros, - ), - bound_at, - )?; - let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id); - let today_inviter_reward_count = if is_admin_invite_code { - 0 - } else { - count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at) - }; - let inviter_reward_granted = !is_admin_invite_code - && module_runtime::should_grant_runtime_profile_inviter_reward(today_inviter_reward_count); - let inviter_balance_after = if inviter_reward_granted { - apply_profile_wallet_delta( - ctx, - &inviter_code.user_id, - PROFILE_REFERRAL_REWARD_POINTS, - RuntimeProfileWalletLedgerSourceType::InviteInviterReward, - &build_runtime_profile_referral_inviter_ledger_id( - &inviter_code.user_id, - validated_input.updated_at_micros, - ), - bound_at, - )? - } else { - profile_wallet_balance(ctx, &inviter_code.user_id) - }; - - ctx.db - .profile_referral_relation() - .insert(ProfileReferralRelation { - invitee_user_id: invitee_user_id.clone(), - inviter_user_id: inviter_code.user_id, - invite_code, - inviter_reward_granted, - invitee_reward_granted: true, - bound_at, - }); - merge_user_account_tags(ctx, &invitee_user_id, invite_metadata_user_tags)?; - - Ok(RuntimeReferralRedeemSnapshot { - center: build_profile_referral_invite_center_snapshot(ctx, &invitee_user_id), - invitee_reward_granted: true, - inviter_reward_granted, - invitee_balance_after, - inviter_balance_after, - }) -} - -fn redeem_profile_reward_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRewardCodeRedeemInput, -) -> Result { - let validated_input = build_runtime_profile_reward_code_redeem_input( - input.user_id, - input.code, - input.redeemed_at_micros, - ) - .map_err(|error| error.to_string())?; - let redeemed_at = Timestamp::from_micros_since_unix_epoch(validated_input.redeemed_at_micros); - let user_id = validated_input.user_id; - let code = validated_input.code; - let redeem_code = ctx - .db - .profile_redeem_code() - .code() - .find(&code) - .ok_or_else(|| "兑换码不存在".to_string())?; - - let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id); - validate_runtime_profile_redeem_code_usage( - &build_profile_redeem_code_snapshot_from_row(&redeem_code), - &user_id, - user_used_count, - validated_input.redeemed_at_micros, - ) - .map_err(|error| error.to_string())?; - - let usage_id = build_runtime_profile_redeem_code_usage_id( - &code, - &user_id, - validated_input.redeemed_at_micros, - user_used_count, - ); - let wallet_ledger_id = build_runtime_profile_redeem_code_ledger_id(&usage_id); - let wallet_balance = apply_profile_wallet_delta( - ctx, - &user_id, - redeem_code.reward_points, - RuntimeProfileWalletLedgerSourceType::RedeemCodeReward, - &wallet_ledger_id, - redeemed_at, - )?; - - ctx.db - .profile_redeem_code_usage() - .insert(ProfileRedeemCodeUsage { - usage_id, - code: code.clone(), - user_id, - amount_granted: redeem_code.reward_points, - created_at: redeemed_at, - }); - - let next_code = ProfileRedeemCode { - global_used_count: redeem_code.global_used_count.saturating_add(1), - updated_at: redeemed_at, - ..redeem_code - }; - ctx.db.profile_redeem_code().code().delete(&code); - ctx.db.profile_redeem_code().insert(next_code); - - let ledger_entry = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&wallet_ledger_id) - .ok_or_else(|| "兑换码钱包流水写入失败".to_string())?; - - Ok(RuntimeProfileRewardCodeRedeemSnapshot { - wallet_balance, - amount_granted: ledger_entry.amount_delta.max(0) as u64, - ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), - }) -} - -fn admin_upsert_profile_redeem_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_redeem_code_admin_upsert_input( - input.admin_user_id, - input.code, - input.mode, - input.reward_points, - input.max_uses, - input.enabled, - input.allowed_user_ids, - input.allowed_public_user_codes, - input.starts_at_micros, - input.expires_at_micros, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let allowed_user_ids = resolve_profile_redeem_code_allowed_user_ids(ctx, &validated_input)?; - let existing = ctx - .db - .profile_redeem_code() - .code() - .find(&validated_input.code); - let action = if existing.is_some() { - "update" - } else { - "create" - }; - let operation_code = validated_input.code.clone(); - let operator_user_id = validated_input.admin_user_id.clone(); - let created_at = existing - .as_ref() - .map(|row| row.created_at) - .unwrap_or(updated_at); - let global_used_count = existing - .as_ref() - .map(|row| row.global_used_count) - .unwrap_or(0); - - if let Some(existing) = existing { - ctx.db.profile_redeem_code().code().delete(&existing.code); - } - - let row = ProfileRedeemCode { - code: validated_input.code, - mode: validated_input.mode, - reward_points: validated_input.reward_points, - max_uses: validated_input.max_uses, - global_used_count, - enabled: validated_input.enabled, - allowed_user_ids, - created_by: validated_input.admin_user_id, - created_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }; - let inserted = ctx.db.profile_redeem_code().insert(row); - insert_profile_code_operation( - ctx, - "redeem", - &operation_code, - action, - &operator_user_id, - updated_at, - ); - Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) -} - -fn admin_disable_profile_redeem_code_record( - ctx: &ReducerContext, - input: RuntimeProfileRedeemCodeAdminDisableInput, -) -> Result { - let validated_input = build_runtime_profile_redeem_code_admin_disable_input( - input.admin_user_id, - input.code, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let existing = ctx - .db - .profile_redeem_code() - .code() - .find(&validated_input.code) - .ok_or_else(|| "兑换码不存在".to_string())?; - - ctx.db.profile_redeem_code().code().delete(&existing.code); - let inserted = ctx.db.profile_redeem_code().insert(ProfileRedeemCode { - enabled: false, - updated_at, - ..existing - }); - insert_profile_code_operation( - ctx, - "redeem", - &validated_input.code, - "disable", - &validated_input.admin_user_id, - updated_at, - ); - Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) -} - -fn admin_upsert_profile_invite_code_record( - ctx: &ReducerContext, - input: RuntimeProfileInviteCodeAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_invite_code_admin_upsert_input( - input.admin_user_id, - input.invite_code, - input.metadata_json, - input.starts_at_micros, - input.expires_at_micros, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let user_id = build_admin_profile_invite_code_user_id( - &validated_input.admin_user_id, - &validated_input.invite_code, - ); - let operation_code = validated_input.invite_code.clone(); - let operator_user_id = validated_input.admin_user_id.clone(); - - if let Some(existing) = ctx - .db - .profile_invite_code() - .invite_code() - .find(&validated_input.invite_code) - { - if existing.user_id != user_id { - return Err("邀请码已被其他用户占用".to_string()); - } - ctx.db - .profile_invite_code() - .user_id() - .delete(&existing.user_id); - let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id, - invite_code: validated_input.invite_code, - metadata_json: validated_input.metadata_json, - created_at: existing.created_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }); - insert_profile_code_operation( - ctx, - "invite", - &operation_code, - "update", - &operator_user_id, - updated_at, - ); - return Ok(build_profile_invite_code_snapshot_from_row(&inserted)); - } - - let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id, - invite_code: validated_input.invite_code, - metadata_json: validated_input.metadata_json, - created_at: updated_at, - updated_at, - starts_at: validated_input - .starts_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - expires_at: validated_input - .expires_at_micros - .map(Timestamp::from_micros_since_unix_epoch), - }); - insert_profile_code_operation( - ctx, - "invite", - &operation_code, - "create", - &operator_user_id, - updated_at, - ); - Ok(build_profile_invite_code_snapshot_from_row(&inserted)) -} - -fn build_profile_referral_invite_center_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeReferralInviteCenterSnapshot { - let code = ensure_profile_invite_code(ctx, user_id); - let today_inviter_reward_count = - count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp); - let invited_relations = ctx - .db - .profile_referral_relation() - .by_profile_referral_inviter_user_id() - .filter(user_id) - .collect::>(); - let invited_count = invited_relations.len() as u32; - let rewarded_invite_count = invited_relations - .iter() - .filter(|row| row.inviter_reward_granted) - .count() as u32; - let bound_relation = ctx - .db - .profile_referral_relation() - .invitee_user_id() - .find(&user_id.to_string()); - - RuntimeReferralInviteCenterSnapshot { - user_id: user_id.to_string(), - invite_code: code.invite_code.clone(), - invite_link_path: build_runtime_profile_invite_link_path(&code.invite_code), - invited_count, - rewarded_invite_count, - today_inviter_reward_count, - today_inviter_reward_remaining: PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT - .saturating_sub(today_inviter_reward_count), - reward_points: PROFILE_REFERRAL_REWARD_POINTS, - invited_users: list_profile_referral_invited_users(ctx, user_id), - has_redeemed_code: bound_relation.is_some(), - bound_inviter_user_id: bound_relation - .as_ref() - .map(|relation| relation.inviter_user_id.clone()), - bound_at_micros: bound_relation - .as_ref() - .map(|relation| relation.bound_at.to_micros_since_unix_epoch()), - updated_at_micros: code.updated_at.to_micros_since_unix_epoch(), - } -} - -fn list_profile_referral_invited_users( - ctx: &ReducerContext, - inviter_user_id: &str, -) -> Vec { - // 中文注释:邀请面板只展示最近成功邀请用户,完整统计仍由计数字段承担。 - let inviter_user_id = inviter_user_id.to_string(); - let mut relations = ctx - .db - .profile_referral_relation() - .by_profile_referral_inviter_user_id() - .filter(&inviter_user_id) - .collect::>(); - - relations.sort_by(|left, right| { - right - .bound_at - .to_micros_since_unix_epoch() - .cmp(&left.bound_at.to_micros_since_unix_epoch()) - }); - - relations - .into_iter() - .take(PROFILE_REFERRAL_INVITED_USERS_LIMIT) - .map(|relation| { - let account = ctx - .db - .user_account() - .user_id() - .find(&relation.invitee_user_id); - RuntimeReferralInvitedUserSnapshot { - user_id: relation.invitee_user_id, - display_name: account - .as_ref() - .map(|user| user.display_name.trim()) - .filter(|name| !name.is_empty()) - .unwrap_or("玩家") - .to_string(), - avatar_url: account.and_then(|user| user.avatar_url), - bound_at_micros: relation.bound_at.to_micros_since_unix_epoch(), - } - }) - .collect() -} - -fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInviteCode { - if let Some(row) = ctx - .db - .profile_invite_code() - .user_id() - .find(&user_id.to_string()) - { - return row; - } - - let mut invite_code = build_runtime_profile_invite_code(user_id, 0); - let mut salt = 1; - while ctx - .db - .profile_invite_code() - .invite_code() - .find(&invite_code) - .is_some() - { - invite_code = build_runtime_profile_invite_code(user_id, salt); - salt += 1; - } - - ctx.db.profile_invite_code().insert(ProfileInviteCode { - user_id: user_id.to_string(), - invite_code, - metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), - created_at: ctx.timestamp, - updated_at: ctx.timestamp, - starts_at: None, - expires_at: None, - }) -} - -fn merge_user_account_tags( - ctx: &ReducerContext, - user_id: &str, - granted_tags: Vec, -) -> Result<(), String> { - let granted_tags = - normalize_profile_user_tags(granted_tags).map_err(|error| error.to_string())?; - if granted_tags.is_empty() { - return Ok(()); - } - - let Some(mut account) = ctx.db.user_account().user_id().find(&user_id.to_string()) else { - return Err("用户不存在".to_string()); - }; - - let mut next_tags = account.user_tags.take().unwrap_or_default(); - next_tags.extend(granted_tags); - account.user_tags = - Some(normalize_profile_user_tags(next_tags).map_err(|error| error.to_string())?); - ctx.db.user_account().user_id().delete(&account.user_id); - ctx.db.user_account().insert(account); - Ok(()) -} - -fn profile_invite_code_metadata_user_tags(metadata_json: &str) -> Result, String> { - let metadata = serde_json::from_str::(metadata_json) - .map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata.to_string())?; - let tags = metadata - .get("userTags") - .or_else(|| metadata.get("user_tags")) - .or_else(|| metadata.get("tags")) - .and_then(JsonValue::as_array) - .map(|items| { - items - .iter() - .filter_map(JsonValue::as_str) - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - normalize_profile_user_tags(tags).map_err(|error| error.to_string()) -} - -fn validate_profile_invite_code_redeem_time( - invite_code: &ProfileInviteCode, - now_micros: i64, -) -> Result<(), String> { - if invite_code - .starts_at - .map(|starts_at| now_micros < starts_at.to_micros_since_unix_epoch()) - .unwrap_or(false) - { - return Err("邀请码未生效".to_string()); - } - - if invite_code - .expires_at - .map(|expires_at| now_micros >= expires_at.to_micros_since_unix_epoch()) - .unwrap_or(false) - { - return Err("邀请码已过期".to_string()); - } - - Ok(()) -} - -fn count_today_profile_referral_inviter_rewards( - ctx: &ReducerContext, - user_id: &str, - now: Timestamp, -) -> u32 { - let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch()); - ctx.db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(user_id) - .filter(|row| { - row.user_id == user_id - && row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward - && row.created_at.to_micros_since_unix_epoch() >= day_start_micros - }) - .count() as u32 -} - -fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool { - user_id.starts_with("admin:") -} - -fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String { - format!("admin:{}:{}", admin_user_id, invite_code) -} - -fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 { - ctx.db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.wallet_balance) - .unwrap_or(0) -} - -fn build_profile_wallet_config_snapshot( - ctx: &ReducerContext, -) -> RuntimeProfileWalletConfigSnapshot { - ctx.db - .profile_wallet_config() - .config_id() - .find(&PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string()) - .map(|row| build_profile_wallet_config_snapshot_from_row(&row)) - .unwrap_or_else(|| RuntimeProfileWalletConfigSnapshot { - config_id: PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string(), - initial_mud_points: PROFILE_NEW_USER_INITIAL_WALLET_POINTS, - created_by: String::new(), - created_at_micros: 0, - updated_by: String::new(), - updated_at_micros: 0, - }) -} - -fn profile_new_user_initial_wallet_points(ctx: &ReducerContext) -> u64 { - build_profile_wallet_config_snapshot(ctx).initial_mud_points -} - -fn build_new_user_registration_wallet_ledger_id(user_id: &str) -> String { - format!("{PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX}:{user_id}") -} - -fn grant_new_user_registration_wallet_reward_tx( - ctx: &ReducerContext, - input: RuntimeProfileDashboardGetInput, -) -> Result { - let validated_input = build_runtime_profile_dashboard_get_input(input.user_id) - .map_err(|error| error.to_string())?; - let ledger_id = build_new_user_registration_wallet_ledger_id(&validated_input.user_id); - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .is_none() - { - apply_profile_wallet_delta( - ctx, - &validated_input.user_id, - profile_new_user_initial_wallet_points(ctx), - RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward, - &ledger_id, - ctx.timestamp, - )?; - } - - get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ) -} - -fn build_profile_recharge_center_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileRechargeCenterSnapshot { - ensure_default_profile_recharge_product_config(ctx); - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let wallet_balance = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()) - .map(|row| row.wallet_balance) - .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, - 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)), - has_points_recharged, - daily_free_points: build_profile_daily_free_points_snapshot(ctx, user_id, ctx.timestamp), - } -} - -fn get_profile_task_center_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileTaskCenterGetInput, - record_login_event: bool, -) -> Result { - let validated_input = build_runtime_profile_task_center_get_input(input.user_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - if record_login_event { - record_daily_login_tracking_event(ctx, &validated_input.user_id)?; - } - - build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp) -} - -fn claim_profile_task_reward_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskClaimInput, -) -> Result { - let validated_input = build_runtime_profile_task_claim_input(input.user_id, input.task_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - let config = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id) - .ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?; - if !config.enabled { - return Err(RuntimeProfileFieldError::TaskDisabled.to_string()); - } - - if is_daily_login_task_config(&config) { - record_daily_login_tracking_event(ctx, &validated_input.user_id)?; - } - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let claim_id = - build_runtime_profile_task_claim_id(&validated_input.user_id, &config.task_id, day_key); - if ctx - .db - .profile_task_reward_claim() - .claim_id() - .find(&claim_id) - .is_some() - { - return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string()); - } - - let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config)?; - if progress_count < config.threshold { - return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string()); - } - - let ledger_id = build_runtime_profile_task_reward_ledger_id( - &validated_input.user_id, - &config.task_id, - day_key, - ); - let wallet_balance = grant_profile_wallet_points( - ctx, - &validated_input.user_id, - config.reward_points, - RuntimeProfileWalletLedgerSourceType::DailyTaskReward, - &ledger_id, - ctx.timestamp, - )?; - let claim = ctx - .db - .profile_task_reward_claim() - .insert(ProfileTaskRewardClaim { - claim_id: claim_id.clone(), - user_id: validated_input.user_id.clone(), - task_id: config.task_id.clone(), - day_key, - reward_points: config.reward_points, - wallet_ledger_id: ledger_id.clone(), - claimed_at: ctx.timestamp, - }); - - refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key)?; - let ledger_entry = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id) - .ok_or_else(|| "任务奖励钱包流水写入失败".to_string())?; - - Ok(RuntimeProfileTaskClaimSnapshot { - user_id: validated_input.user_id.clone(), - task_id: config.task_id.clone(), - day_key, - reward_points: claim.reward_points, - wallet_balance, - ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry), - center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)?, - }) -} - -fn list_profile_task_config_snapshots( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminListInput, -) -> Result, String> { - let _validated_input = build_runtime_profile_task_config_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - ensure_default_profile_task_config(ctx); - - let mut entries = ctx - .db - .profile_task_config() - .iter() - .map(|row| build_profile_task_config_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.task_id.cmp(&right.task_id)) - }); - Ok(entries) -} - -fn get_profile_wallet_config_snapshot( - ctx: &ReducerContext, - input: RuntimeProfileWalletConfigAdminGetInput, -) -> Result { - let _validated_input = build_runtime_profile_wallet_config_admin_get_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - Ok(build_profile_wallet_config_snapshot(ctx)) -} - -fn upsert_profile_wallet_config_record( - ctx: &ReducerContext, - input: RuntimeProfileWalletConfigAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_wallet_config_admin_upsert_input( - input.admin_user_id, - input.initial_mud_points, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let config_id = PROFILE_WALLET_CONFIG_GLOBAL_ID.to_string(); - let existing = ctx.db.profile_wallet_config().config_id().find(&config_id); - if let Some(row) = existing.as_ref() { - ctx.db - .profile_wallet_config() - .config_id() - .delete(&row.config_id); - } - let inserted = ctx.db.profile_wallet_config().insert(ProfileWalletConfig { - config_id, - initial_mud_points: validated_input.initial_mud_points, - 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_wallet_config_snapshot_from_row(&inserted)) -} - -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, -) -> Result< - ( - Vec, - Vec, - ), - String, -> { - let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_redeem_code() - .iter() - .map(|row| build_profile_redeem_code_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .updated_at_micros - .cmp(&left.updated_at_micros) - .then_with(|| left.code.cmp(&right.code)) - }); - Ok((entries, profile_code_operation_snapshots(ctx, "redeem"))) -} - -fn admin_list_profile_invite_code_records( - ctx: &ReducerContext, - input: RuntimeProfileInviteCodeAdminListInput, -) -> Result< - ( - Vec, - Vec, - ), - String, -> { - let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id) - .map_err(|error| error.to_string())?; - - let mut entries = ctx - .db - .profile_invite_code() - .iter() - .filter(|row| is_admin_profile_invite_code_user_id(&row.user_id)) - .map(|row| build_profile_invite_code_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .updated_at_micros - .cmp(&left.updated_at_micros) - .then_with(|| left.invite_code.cmp(&right.invite_code)) - }); - Ok((entries, profile_code_operation_snapshots(ctx, "invite"))) -} - -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.membership_period_points, - input.membership_period_days, - input.membership_queue_limit, - input.membership_discount_bps, - 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, - membership_period_points: validated_input.membership_period_points, - membership_period_days: validated_input.membership_period_days, - membership_queue_limit: validated_input.membership_queue_limit, - membership_discount_bps: validated_input.membership_discount_bps, - }); - Ok(build_profile_recharge_product_config_snapshot_from_row( - &inserted, - )) -} - -fn upsert_profile_task_config_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminUpsertInput, -) -> Result { - let validated_input = build_runtime_profile_task_config_admin_upsert_input( - input.admin_user_id, - input.task_id, - input.title, - input.description, - input.event_key, - input.cycle, - input.scope_kind, - input.threshold, - input.reward_points, - input.enabled, - input.sort_order, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - let existing = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id); - if let Some(row) = existing.as_ref() { - ctx.db.profile_task_config().task_id().delete(&row.task_id); - } - - let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig { - task_id: validated_input.task_id, - title: validated_input.title, - description: validated_input.description, - event_key: validated_input.event_key, - cycle: validated_input.cycle, - scope_kind: validated_input.scope_kind, - threshold: validated_input.threshold, - reward_points: validated_input.reward_points, - 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_task_config_snapshot_from_row(&inserted)) -} - -fn disable_profile_task_config_record( - ctx: &ReducerContext, - input: RuntimeProfileTaskConfigAdminDisableInput, -) -> Result { - let validated_input = build_runtime_profile_task_config_admin_disable_input( - input.admin_user_id, - input.task_id, - input.updated_at_micros, - ) - .map_err(|error| error.to_string())?; - let row = ctx - .db - .profile_task_config() - .task_id() - .find(&validated_input.task_id) - .ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?; - let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); - ctx.db.profile_task_config().task_id().delete(&row.task_id); - let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig { - enabled: false, - updated_by: validated_input.admin_user_id, - updated_at, - ..row - }); - Ok(build_profile_task_config_snapshot_from_row(&inserted)) -} - -fn build_profile_task_center_snapshot( - ctx: &ReducerContext, - user_id: &str, - updated_at: Timestamp, -) -> Result { - ensure_default_profile_task_config(ctx); - refresh_profile_wallet_expiring_points(ctx, user_id, updated_at); - let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch()); - let mut configs = ctx.db.profile_task_config().iter().collect::>(); - configs.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.task_id.cmp(&right.task_id)) - }); - let mut tasks = Vec::with_capacity(configs.len()); - for config in configs { - validate_profile_task_user_scope(&config)?; - let progress_count = profile_task_progress_count(ctx, user_id, &config)?; - refresh_profile_task_progress(ctx, user_id, &config, day_key)?; - let claim = ctx.db.profile_task_reward_claim().claim_id().find( - &build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key), - ); - tasks.push(RuntimeProfileTaskItemSnapshot { - task_id: config.task_id, - title: config.title, - description: config.description, - event_key: config.event_key, - cycle: config.cycle, - threshold: config.threshold, - progress_count, - reward_points: config.reward_points, - status: resolve_runtime_profile_task_status( - config.enabled, - progress_count, - config.threshold, - claim.is_some(), - ), - day_key, - claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()), - updated_at_micros: updated_at.to_micros_since_unix_epoch(), - }); - } - - Ok(RuntimeProfileTaskCenterSnapshot { - user_id: user_id.to_string(), - day_key, - wallet_balance: profile_wallet_balance(ctx, user_id), - tasks, - updated_at_micros: updated_at.to_micros_since_unix_epoch(), - }) -} - -fn query_analytics_metric_buckets( - ctx: &ReducerContext, - input: AnalyticsMetricQueryInput, -) -> Result, String> { - let validated_input = build_analytics_metric_query_input( - input.event_key, - input.scope_kind, - input.scope_id, - input.granularity, - ) - .map_err(|error| error.to_string())?; - let stats = ctx - .db - .tracking_daily_stat() - .by_tracking_daily_stat_scope_day() - .filter(( - validated_input.scope_kind, - validated_input.scope_id.as_str(), - )) - .filter(|row| { - row.event_key.trim() == validated_input.event_key - && row.scope_kind == validated_input.scope_kind - && row.scope_id.trim() == validated_input.scope_id - }) - .map(|row| RuntimeAnalyticsDailyStatSnapshot { - event_key: row.event_key, - scope_kind: row.scope_kind, - scope_id: row.scope_id, - day_key: row.day_key, - count: row.count, - }) - .collect::>(); - - Ok(aggregate_runtime_tracking_daily_stats( - stats, - &validated_input.event_key, - validated_input.scope_kind, - &validated_input.scope_id, - validated_input.granularity, - )) -} - -fn refresh_profile_task_progress( - ctx: &ReducerContext, - user_id: &str, - config: &ProfileTaskConfig, - day_key: i64, -) -> Result { - let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key); - if let Some(existing) = ctx - .db - .profile_task_progress() - .progress_id() - .find(&progress_id) - { - ctx.db - .profile_task_progress() - .progress_id() - .delete(&existing.progress_id); - } - let progress_count = profile_task_progress_count(ctx, user_id, config)?; - let claimed = ctx - .db - .profile_task_reward_claim() - .claim_id() - .find(&build_runtime_profile_task_claim_id( - user_id, - &config.task_id, - day_key, - )) - .is_some(); - Ok(ctx.db.profile_task_progress().insert(ProfileTaskProgress { - progress_id, - user_id: user_id.to_string(), - task_id: config.task_id.clone(), - day_key, - progress_count, - threshold: config.threshold, - status: resolve_runtime_profile_task_status( - config.enabled, - progress_count, - config.threshold, - claimed, - ), - updated_at: ctx.timestamp, - })) -} - -fn profile_task_progress_count( - ctx: &ReducerContext, - user_id: &str, - config: &ProfileTaskConfig, -) -> Result { - validate_profile_task_user_scope(config)?; - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let scope_id = profile_task_tracking_scope_id(user_id, config)?; - Ok(ctx - .db - .tracking_daily_stat() - .stat_id() - .find(&build_runtime_tracking_daily_stat_id( - &config.event_key, - config.scope_kind, - &scope_id, - day_key, - )) - .map(|row| row.count) - .unwrap_or(0)) -} - -fn profile_task_tracking_scope_id( - user_id: &str, - config: &ProfileTaskConfig, -) -> Result { - validate_profile_task_user_scope(config)?; - Ok(user_id.to_string()) -} - -fn insert_profile_code_operation( - ctx: &ReducerContext, - code_kind: &str, - code: &str, - action: &str, - operator_user_id: &str, - created_at: Timestamp, -) { - let created_at_micros = created_at.to_micros_since_unix_epoch(); - let operation_index = ctx - .db - .profile_code_operation() - .by_profile_code_operation_kind_code() - .filter((code_kind, code)) - .count(); - ctx.db - .profile_code_operation() - .insert(ProfileCodeOperation { - operation_id: format!( - "{}:{}:{}:{}:{}", - code_kind.trim(), - code.trim(), - action.trim(), - created_at_micros, - operation_index - ), - code_kind: code_kind.to_string(), - code: code.to_string(), - action: action.to_string(), - operator_user_id: operator_user_id.to_string(), - created_at, - }); -} - -fn profile_code_operation_snapshots( - ctx: &ReducerContext, - code_kind: &str, -) -> Vec { - let mut entries = ctx - .db - .profile_code_operation() - .by_profile_code_operation_code_kind() - .filter(code_kind) - .map(|row| build_profile_code_operation_snapshot_from_row(&row)) - .collect::>(); - entries.sort_by(|left, right| { - right - .created_at_micros - .cmp(&left.created_at_micros) - .then_with(|| left.code.cmp(&right.code)) - .then_with(|| left.operation_id.cmp(&right.operation_id)) - }); - entries -} - -fn validate_profile_task_user_scope(config: &ProfileTaskConfig) -> Result<(), String> { - if config.scope_kind == RuntimeTrackingScopeKind::User { - Ok(()) - } else { - Err(format!( - "个人任务 scope_kind 首版仅支持 user,当前 task_id={} scope_kind={}", - config.task_id, - config.scope_kind.as_str() - )) - } -} - -fn is_daily_login_task_config(config: &ProfileTaskConfig) -> bool { - config.task_id == PROFILE_TASK_ID_DAILY_LOGIN - && config.event_key == PROFILE_TASK_EVENT_KEY_DAILY_LOGIN - && config.scope_kind == RuntimeTrackingScopeKind::User -} - -fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Result<(), String> { - let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch()); - let event_id = format!( - "{}:{}:{}", - PROFILE_TASK_LOGIN_EVENT_ID_PREFIX, - user_id.trim(), - day_key - ); - if ctx.db.tracking_event().event_id().find(&event_id).is_some() { - return Ok(()); - } - - record_tracking_event( - ctx, - RuntimeTrackingEventInput { - event_id, - event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(), - scope_kind: RuntimeTrackingScopeKind::User, - scope_id: user_id.to_string(), - user_id: Some(user_id.to_string()), - owner_user_id: None, - profile_id: None, - module_key: Some(PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string()), - metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), - occurred_at_micros: ctx.timestamp.to_micros_since_unix_epoch(), - }, - ) -} - -fn should_skip_existing_tracking_event_id(event_exists: bool) -> bool { - event_exists -} - -fn record_tracking_event( - ctx: &ReducerContext, - input: RuntimeTrackingEventInput, -) -> Result<(), String> { - let validated_input = build_runtime_tracking_event_input( - input.event_id, - input.event_key, - input.scope_kind, - input.scope_id, - input.user_id, - input.owner_user_id, - input.profile_id, - input.module_key, - input.metadata_json, - input.occurred_at_micros, - ) - .map_err(|error| error.to_string())?; - let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros); - let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros); - if should_skip_existing_tracking_event_id( - ctx.db - .tracking_event() - .event_id() - .find(&validated_input.event_id) - .is_some(), - ) { - return Ok(()); - } - // 中文注释:埋点事实与日期维表使用同一北京时间业务日桶,先幂等补齐维表,避免后续周/月/季/年聚合缺少 bucket 映射。 - ensure_analytics_date_dimension_row(ctx, day_key)?; - ctx.db.tracking_event().insert(TrackingEvent { - event_id: validated_input.event_id, - event_key: validated_input.event_key.clone(), - scope_kind: validated_input.scope_kind, - scope_id: validated_input.scope_id.clone(), - day_key, - user_id: validated_input.user_id, - owner_user_id: validated_input.owner_user_id, - profile_id: validated_input.profile_id, - module_key: validated_input.module_key, - metadata_json: validated_input.metadata_json, - occurred_at, - }); - upsert_tracking_daily_stat( - ctx, - &validated_input.event_key, - validated_input.scope_kind, - &validated_input.scope_id, - day_key, - occurred_at, - ); - Ok(()) -} - -fn upsert_tracking_daily_stat( - ctx: &ReducerContext, - event_key: &str, - scope_kind: RuntimeTrackingScopeKind, - scope_id: &str, - day_key: i64, - occurred_at: Timestamp, -) { - let stat_id = build_runtime_tracking_daily_stat_id(event_key, scope_kind, scope_id, day_key); - let existing = ctx.db.tracking_daily_stat().stat_id().find(&stat_id); - if let Some(row) = existing { - ctx.db.tracking_daily_stat().stat_id().delete(&row.stat_id); - ctx.db.tracking_daily_stat().insert(TrackingDailyStat { - stat_id, - event_key: row.event_key, - scope_kind: row.scope_kind, - scope_id: row.scope_id, - day_key: row.day_key, - count: row.count.saturating_add(1), - first_occurred_at: row.first_occurred_at, - last_occurred_at: occurred_at, - updated_at: occurred_at, - }); - } else { - ctx.db.tracking_daily_stat().insert(TrackingDailyStat { - stat_id, - event_key: event_key.to_string(), - scope_kind, - scope_id: scope_id.to_string(), - day_key, - count: 1, - first_occurred_at: occurred_at, - last_occurred_at: occurred_at, - updated_at: occurred_at, - }); - } -} - -fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig { - if let Some(row) = ctx - .db - .profile_task_config() - .task_id() - .find(&PROFILE_TASK_ID_DAILY_LOGIN.to_string()) - { - return row; - } - - let default_config = build_default_runtime_profile_task_config( - ctx.timestamp.to_micros_since_unix_epoch(), - PROFILE_TASK_SYSTEM_USER_ID.to_string(), - ); - ctx.db.profile_task_config().insert(ProfileTaskConfig { - task_id: default_config.task_id, - title: default_config.title, - description: default_config.description, - event_key: default_config.event_key, - cycle: default_config.cycle, - scope_kind: default_config.scope_kind, - threshold: default_config.threshold, - reward_points: default_config.reward_points, - enabled: default_config.enabled, - sort_order: default_config.sort_order, - created_by: default_config.created_by, - created_at: ctx.timestamp, - updated_by: default_config.updated_by, - updated_at: ctx.timestamp, - }) -} - -fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) { - if ctx.db.profile_recharge_product_config().count() > 0 { - migrate_legacy_default_profile_recharge_product_config(ctx); - return; - } - - 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, - membership_period_points: product.membership_period_points, - membership_period_days: product.membership_period_days, - membership_queue_limit: product.membership_queue_limit, - membership_discount_bps: product.membership_discount_bps, - }); - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DefaultPointProductMigration { - bonus_points: u64, - badge_label: &'static str, - description: &'static str, - enabled: bool, -} - -fn resolve_default_point_product_migration( - row: &ProfileRechargeProductConfig, -) -> Option { - if row.kind != RuntimeProfileRechargeProductKind::Points - || row.tier != RuntimeProfileMembershipTier::Normal - || row.updated_by != PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID - || !row.enabled - || row.duration_days != 0 - || row.bonus_points != row.points_amount - || row.badge_label != "首充双倍" - || row.description != format!("首充送{}泥点", row.points_amount) - { - return None; - } - - let expected_product_id = format!("points_{}", row.points_amount); - let expected_title = format!("{}泥点", row.points_amount); - if row.product_id != expected_product_id - || row.title != expected_title - || row.price_cents != row.points_amount.saturating_mul(10) - { - return None; - } - - match row.points_amount { - 60 => Some(DefaultPointProductMigration { - bonus_points: 0, - badge_label: "", - description: "60泥点", - enabled: true, - }), - 180 => Some(DefaultPointProductMigration { - bonus_points: 90, - badge_label: "首充加赠", - description: "首充加赠90泥点", - enabled: true, - }), - 300 => Some(DefaultPointProductMigration { - bonus_points: 150, - badge_label: "首充加赠", - description: "首充加赠150泥点", - enabled: true, - }), - 680 => Some(DefaultPointProductMigration { - bonus_points: 340, - badge_label: "首充加赠", - description: "首充加赠340泥点", - enabled: true, - }), - 1_280 | 3_280 => Some(DefaultPointProductMigration { - bonus_points: row.bonus_points, - badge_label: "首充双倍", - description: "", - enabled: false, - }), - _ => None, - } -} - -fn migrate_legacy_default_profile_recharge_product_config(ctx: &ReducerContext) { - let rows = ctx - .db - .profile_recharge_product_config() - .iter() - .collect::>(); - for mut row in rows { - let Some(migration) = resolve_default_point_product_migration(&row) else { - continue; - }; - row.bonus_points = migration.bonus_points; - row.badge_label = migration.badge_label.to_string(); - if !migration.description.is_empty() { - row.description = migration.description.to_string(); - } - row.enabled = migration.enabled; - row.updated_at = ctx.timestamp; - ctx.db - .profile_recharge_product_config() - .product_id() - .update(row); - } -} - -fn profile_recharge_product_config_rows( - ctx: &ReducerContext, - include_disabled: bool, -) -> 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 -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ProfileMembershipPurchaseResult { - expires_at: Timestamp, - period_points_delta: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct MembershipCyclePointMutation { - points: u64, - cycle_resets_at_micros: Option, -} - -impl MembershipCyclePointMutation { - fn none() -> Self { - Self { - points: 0, - cycle_resets_at_micros: None, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreePointMutation { - points: u64, - day_key: Option, -} - -impl DailyFreePointMutation { - fn none() -> Self { - Self { - points: 0, - day_key: None, - } - } -} - -struct MembershipCycleInitialization { - row: ProfileMembership, - granted_points_delta: u64, - cycle_resets_at: Timestamp, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ActiveMembershipPurchaseMode { - Renew, - Upgrade, -} - -fn normalized_membership_period_days(period_days: u32) -> u32 { - if period_days == 0 { - PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS - } else { - period_days - } -} - -fn membership_period_micros(period_days: u32) -> i64 { - i64::from(normalized_membership_period_days(period_days)) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) -} - -fn membership_cycle_reset_at(started_at: Timestamp, period_days: u32) -> Timestamp { - Timestamp::from_micros_since_unix_epoch( - started_at - .to_micros_since_unix_epoch() - .saturating_add(membership_period_micros(period_days)), - ) -} - -fn membership_product_period_points(product: &RuntimeProfileRechargeProductSnapshot) -> u64 { - if product.membership_period_points == 0 { - runtime_profile_membership_period_points(product.tier) - } else { - product.membership_period_points - } -} - -fn membership_product_period_days(product: &RuntimeProfileRechargeProductSnapshot) -> u32 { - normalized_membership_period_days(product.membership_period_days) -} - -fn active_membership_row_at(row: &ProfileMembership, at: Timestamp) -> bool { - row.expires_at.to_micros_since_unix_epoch() > at.to_micros_since_unix_epoch() -} - -fn canonical_membership_pricing_tier( - tier: RuntimeProfileMembershipTier, -) -> RuntimeProfileMembershipTier { - match tier { - RuntimeProfileMembershipTier::Month => RuntimeProfileMembershipTier::Starter, - RuntimeProfileMembershipTier::Season => RuntimeProfileMembershipTier::Basic, - RuntimeProfileMembershipTier::Year => RuntimeProfileMembershipTier::Pro, - _ => tier, - } -} - -fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool { - matches!( - tier, - RuntimeProfileMembershipTier::Month - | RuntimeProfileMembershipTier::Season - | RuntimeProfileMembershipTier::Year - ) -} - -fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 { - runtime_profile_membership_tier_rank(tier).unwrap_or(0) -} - -fn resolve_active_membership_purchase_mode( - current_tier: RuntimeProfileMembershipTier, - next_tier: RuntimeProfileMembershipTier, -) -> Result { - if current_tier == next_tier { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - let current_rank = active_membership_tier_rank(current_tier); - let next_rank = active_membership_tier_rank(next_tier); - let is_legacy_equivalent_migration = current_rank == next_rank - && is_legacy_membership_tier(current_tier) - && canonical_membership_pricing_tier(current_tier) == next_tier; - - if is_legacy_equivalent_migration { - return Ok(ActiveMembershipPurchaseMode::Renew); - } - - if next_rank > current_rank { - return Ok(ActiveMembershipPurchaseMode::Upgrade); - } - - Err("当前会员有效期内不能购买更低档会员".to_string()) -} - -fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 { - target_price_cents.saturating_sub(current_price_cents) -} - -fn membership_product_price_cents_by_tier( - ctx: &ReducerContext, - tier: RuntimeProfileMembershipTier, -) -> Option { - let canonical_tier = canonical_membership_pricing_tier(tier); - profile_recharge_product_config_rows(ctx, false) - .into_iter() - .find(|row| { - row.kind == RuntimeProfileRechargeProductKind::Membership - && canonical_membership_pricing_tier(row.tier) == canonical_tier - }) - .map(|row| row.price_cents) - .or_else(|| { - runtime_profile_recharge_membership_products() - .into_iter() - .find(|product| canonical_membership_pricing_tier(product.tier) == canonical_tier) - .map(|product| product.price_cents) - }) -} - -fn resolve_profile_membership_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - let Some(row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - .filter(|row| active_membership_row_at(row, created_at)) - else { - return Ok(product.price_cents); - }; - - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - return Ok(product.price_cents); - } - - let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0); - Ok(membership_upgrade_amount_cents( - product.price_cents, - current_price_cents, - )) -} - -fn resolve_profile_recharge_order_amount_cents( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - created_at: Timestamp, -) -> Result { - match product.kind { - RuntimeProfileRechargeProductKind::Points => Ok(product.price_cents), - RuntimeProfileRechargeProductKind::Membership => { - resolve_profile_membership_order_amount_cents(ctx, user_id, product, created_at) - } - } -} - -fn apply_active_membership_upgrade_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - period_days: u32, - period_points: u64, - updated_at: Timestamp, -) -> (ProfileMembership, u64) { - let period_points_delta = period_points.saturating_sub(row.cycle_granted_points); - row.tier = tier; - row.cycle_granted_points = row.cycle_granted_points.max(period_points); - row.cycle_remaining_points = row - .cycle_remaining_points - .saturating_add(period_points_delta); - row.cycle_period_days = period_days; - row.updated_at = updated_at; - (row, period_points_delta) -} - -fn apply_active_membership_renew_row( - mut row: ProfileMembership, - tier: RuntimeProfileMembershipTier, - duration_days: u32, - purchased_at: Timestamp, -) -> ProfileMembership { - row.tier = tier; - row.expires_at = Timestamp::from_micros_since_unix_epoch( - row.expires_at - .to_micros_since_unix_epoch() - .saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)), - ); - row.updated_at = purchased_at; - row -} - -fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) { - ctx.db.profile_membership().user_id().delete(&row.user_id); - ctx.db.profile_membership().insert(row); -} - -fn initialize_missing_profile_membership_cycle( - mut row: ProfileMembership, - now: Timestamp, -) -> Option { - if row.cycle_resets_at.is_some() { - return None; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut cycle_started_at = row.cycle_started_at.unwrap_or(row.started_at); - let mut next_reset_at = membership_cycle_reset_at(cycle_started_at, period_days); - let now_micros = now.to_micros_since_unix_epoch(); - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - let granted_points_delta = granted_points.saturating_sub(row.cycle_remaining_points); - row.cycle_started_at = Some(cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - - Some(MembershipCycleInitialization { - row, - granted_points_delta, - cycle_resets_at: next_reset_at, - }) -} - -fn membership_cycle_metadata( - action: &str, - expired_points: u64, - granted_points: u64, - cycle_resets_at: Option, -) -> String { - serde_json::to_string(&json!({ - "action": action, - "expiredMembershipPeriodPoints": expired_points, - "grantedMembershipPeriodPoints": granted_points, - "cycleResetsAtMicros": cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - })) - .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) -} - -fn update_profile_wallet_balance_for_expiring_points( - ctx: &ReducerContext, - user_id: &str, - expired_points: u64, - granted_points: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - metadata_json: String, -) { - if expired_points == 0 && granted_points == 0 { - return; - } - if ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id.to_string()) - .is_some() - { - return; - } - - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let after_expiry = previous_balance.saturating_sub(expired_points); - let next_balance = after_expiry.saturating_add(granted_points); - let created_state_at = current - .as_ref() - .map(|row| row.created_at) - .unwrap_or(created_at); - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: created_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: 0, - created_at: created_state_at, - updated_at: created_at, - }); - } - - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta: next_balance as i64 - previous_balance as i64, - balance_after: next_balance, - source_type, - created_at, - metadata_json: Some(metadata_json), - }); -} - -fn daily_free_points_metadata( - action: &str, - expired_points: u64, - granted_points: u64, - day_key: i64, -) -> String { - serde_json::to_string(&json!({ - "action": action, - "dailyFreeDayKey": day_key, - "expiredDailyFreePoints": expired_points, - "grantedDailyFreePoints": granted_points, - })) - .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreeRefreshPlan { - expired_points: u64, - granted_points: u64, - reset: bool, -} - -fn resolve_daily_free_refresh_plan( - current_day_key: Option, - current_remaining_points: u64, - day_key: i64, -) -> Option { - match current_day_key { - Some(current_day_key) if current_day_key >= day_key => None, - Some(_) => Some(DailyFreeRefreshPlan { - expired_points: current_remaining_points, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - reset: true, - }), - None => Some(DailyFreeRefreshPlan { - expired_points: 0, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - reset: false, - }), - } -} - -fn refresh_profile_daily_free_points(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); - let current = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()); - let Some(plan) = resolve_daily_free_refresh_plan( - current.as_ref().map(|row| row.day_key), - current - .as_ref() - .map(|row| row.remaining_points) - .unwrap_or(0), - day_key, - ) else { - return; - }; - - match current { - Some(row) => { - debug_assert!(plan.reset); - ctx.db - .profile_daily_free_points() - .user_id() - .update(ProfileDailyFreePoints { - day_key, - granted_points: plan.granted_points, - remaining_points: plan.granted_points, - updated_at: now, - ..row - }); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - plan.expired_points, - plan.granted_points, - RuntimeProfileWalletLedgerSourceType::DailyFreeReset, - &format!("daily-free-reset:{user_id}:{day_key}"), - now, - daily_free_points_metadata( - "reset", - plan.expired_points, - plan.granted_points, - day_key, - ), - ); - } - None => { - debug_assert!(!plan.reset); - ctx.db - .profile_daily_free_points() - .insert(ProfileDailyFreePoints { - user_id: user_id.to_string(), - day_key, - granted_points: plan.granted_points, - remaining_points: plan.granted_points, - updated_at: now, - }); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - plan.granted_points, - RuntimeProfileWalletLedgerSourceType::DailyFreeGrant, - &format!("daily-free-grant:{user_id}:{day_key}"), - now, - daily_free_points_metadata("grant", 0, plan.granted_points, day_key), - ); - } - } -} - -fn profile_daily_free_points_resets_at_micros(day_key: i64) -> i64 { - day_key - .saturating_add(1) - .saturating_mul(PROFILE_RUNTIME_DAY_MICROS) - .saturating_sub(PROFILE_TASK_BEIJING_OFFSET_MICROS) -} - -fn build_profile_daily_free_points_snapshot( - ctx: &ReducerContext, - user_id: &str, - now: Timestamp, -) -> RuntimeProfileDailyFreePointsSnapshot { - let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); - ctx.db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - .map(|row| RuntimeProfileDailyFreePointsSnapshot { - day_key: row.day_key, - granted_points: row.granted_points, - remaining_points: row.remaining_points, - resets_at_micros: profile_daily_free_points_resets_at_micros(row.day_key), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - }) - .unwrap_or(RuntimeProfileDailyFreePointsSnapshot { - day_key, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - remaining_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - resets_at_micros: profile_daily_free_points_resets_at_micros(day_key), - updated_at_micros: now.to_micros_since_unix_epoch(), - }) -} - -fn refresh_profile_wallet_expiring_points( - ctx: &ReducerContext, - user_id: &str, - membership_now: Timestamp, -) { - refresh_profile_daily_free_points(ctx, user_id, ctx.timestamp); - refresh_profile_membership_cycle(ctx, user_id, membership_now); -} - -fn refresh_profile_membership_cycle(ctx: &ReducerContext, user_id: &str, now: Timestamp) { - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - - let now_micros = now.to_micros_since_unix_epoch(); - let expires_at_micros = row.expires_at.to_micros_since_unix_epoch(); - if expires_at_micros <= now_micros { - let expired_points = row.cycle_remaining_points; - if row.status != RuntimeProfileMembershipStatus::Normal || expired_points > 0 { - row.status = RuntimeProfileMembershipStatus::Normal; - row.cycle_remaining_points = 0; - row.updated_at = now; - let ledger_id = format!("membership-period-expire:{user_id}:{expires_at_micros}"); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - expired_points, - 0, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, - &ledger_id, - now, - membership_cycle_metadata("expire", expired_points, 0, None), - ); - } - return; - } - - if row.cycle_resets_at.is_none() { - let Some(initialization) = initialize_missing_profile_membership_cycle(row, now) else { - return; - }; - let ledger_id = format!( - "membership-period-initialize:{user_id}:{}", - initialization.cycle_resets_at.to_micros_since_unix_epoch() - ); - let granted_points_delta = initialization.granted_points_delta; - row = initialization.row; - let cycle_resets_at = row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - granted_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - now, - membership_cycle_metadata("initialize", 0, granted_points_delta, cycle_resets_at), - ); - let Some(refreshed_row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return; - }; - row = refreshed_row; - } - - let Some(mut next_reset_at) = row.cycle_resets_at else { - return; - }; - if next_reset_at.to_micros_since_unix_epoch() > now_micros { - return; - } - - let period_days = normalized_membership_period_days(row.cycle_period_days); - let period_micros = membership_period_micros(period_days); - let mut next_cycle_started_at = next_reset_at; - let mut guard = 0u32; - while next_reset_at.to_micros_since_unix_epoch() <= now_micros && guard < 120 { - next_cycle_started_at = next_reset_at; - next_reset_at = Timestamp::from_micros_since_unix_epoch( - next_reset_at - .to_micros_since_unix_epoch() - .saturating_add(period_micros), - ); - guard = guard.saturating_add(1); - } - - let expired_points = row.cycle_remaining_points; - let granted_points = - runtime_profile_membership_period_points(row.tier).max(row.cycle_granted_points); - row.cycle_started_at = Some(next_cycle_started_at); - row.cycle_resets_at = Some(next_reset_at); - row.cycle_granted_points = granted_points; - row.cycle_remaining_points = granted_points; - row.cycle_period_days = period_days; - row.updated_at = now; - let reset_micros = next_cycle_started_at.to_micros_since_unix_epoch(); - let ledger_id = format!("membership-period-reset:{user_id}:{reset_micros}"); - upsert_profile_membership_row(ctx, row); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - expired_points, - granted_points, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset, - &ledger_id, - now, - membership_cycle_metadata("reset", expired_points, granted_points, Some(next_reset_at)), - ); -} - -fn build_profile_membership_snapshot( - ctx: &ReducerContext, - user_id: &str, -) -> RuntimeProfileMembershipSnapshot { - refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); - let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - let membership = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()); - match membership { - Some(row) if row.expires_at.to_micros_since_unix_epoch() > now_micros => { - RuntimeProfileMembershipSnapshot { - user_id: row.user_id, - status: row.status, - tier: row.tier, - started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), - expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), - updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), - cycle_started_at_micros: row - .cycle_started_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_resets_at_micros: row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_granted_points: row.cycle_granted_points, - cycle_remaining_points: row.cycle_remaining_points, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), - } - } - Some(row) => RuntimeProfileMembershipSnapshot { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, - started_at_micros: Some(row.started_at.to_micros_since_unix_epoch()), - expires_at_micros: Some(row.expires_at.to_micros_since_unix_epoch()), - updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()), - cycle_started_at_micros: row - .cycle_started_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_resets_at_micros: row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()), - cycle_granted_points: row.cycle_granted_points, - cycle_remaining_points: 0, - cycle_period_days: normalized_membership_period_days(row.cycle_period_days), - }, - None => RuntimeProfileMembershipSnapshot { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Normal, - tier: RuntimeProfileMembershipTier::Normal, - started_at_micros: None, - expires_at_micros: None, - updated_at_micros: None, - cycle_started_at_micros: None, - cycle_resets_at_micros: None, - cycle_granted_points: 0, - cycle_remaining_points: 0, - cycle_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS, - }, - } -} - -fn apply_profile_membership_purchase( - ctx: &ReducerContext, - user_id: &str, - product: &RuntimeProfileRechargeProductSnapshot, - purchased_at: Timestamp, -) -> Result { - refresh_profile_wallet_expiring_points(ctx, user_id, purchased_at); - let tier = product.tier; - let duration_days = product.duration_days; - let period_days = membership_product_period_days(product); - let period_points = membership_product_period_points(product); - let current = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()); - let purchased_at_micros = purchased_at.to_micros_since_unix_epoch(); - let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS); - - let (next_row, period_points_delta) = match current { - Some(row) if active_membership_row_at(&row, purchased_at) => { - let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?; - if purchase_mode == ActiveMembershipPurchaseMode::Renew { - ( - apply_active_membership_renew_row(row, tier, duration_days, purchased_at), - 0, - ) - } else { - apply_active_membership_upgrade_row( - row, - tier, - period_days, - period_points, - purchased_at, - ) - } - } - Some(row) => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: row.user_id, - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - None => { - let expires_at = Timestamp::from_micros_since_unix_epoch( - purchased_at_micros.saturating_add(duration_micros), - ); - ( - ProfileMembership { - user_id: user_id.to_string(), - status: RuntimeProfileMembershipStatus::Active, - tier, - started_at: purchased_at, - expires_at, - updated_at: purchased_at, - cycle_started_at: Some(purchased_at), - cycle_resets_at: Some(membership_cycle_reset_at(purchased_at, period_days)), - cycle_granted_points: period_points, - cycle_remaining_points: period_points, - cycle_period_days: period_days, - }, - period_points, - ) - } - }; - - let expires_at = next_row.expires_at; - let cycle_resets_at = next_row.cycle_resets_at.clone(); - upsert_profile_membership_row(ctx, next_row); - - if period_points_delta > 0 { - let ledger_id = format!( - "membership-period-grant:{user_id}:{}:{}", - purchased_at_micros, product.product_id - ); - update_profile_wallet_balance_for_expiring_points( - ctx, - user_id, - 0, - period_points_delta, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant, - &ledger_id, - purchased_at, - membership_cycle_metadata("purchase", 0, period_points_delta, cycle_resets_at), - ); - } - - Ok(ProfileMembershipPurchaseResult { - expires_at, - period_points_delta, - }) -} - -fn apply_profile_wallet_delta( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, -) -> Result { - let amount_delta = convert_runtime_profile_wallet_unsigned_delta(amount_delta) - .map_err(|error| error.to_string())?; - apply_profile_wallet_signed_delta( - ctx, - user_id, - amount_delta, - source_type, - ledger_id, - created_at, - false, - PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON, - ) -} - -pub(crate) fn grant_profile_wallet_points( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, -) -> Result { - apply_profile_wallet_delta( - ctx, - user_id, - amount_delta, - source_type, - ledger_id, - created_at, - ) -} - -pub(crate) fn grant_profile_wallet_points_with_metadata( - ctx: &ReducerContext, - user_id: &str, - amount_delta: u64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - metadata_json: &str, -) -> Result { - apply_profile_wallet_signed_delta( - ctx, - user_id, - convert_runtime_profile_wallet_unsigned_delta(amount_delta) - .map_err(|error| error.to_string())?, - source_type, - ledger_id, - created_at, - true, - metadata_json, - ) -} - -fn apply_profile_wallet_adjustment( - ctx: &ReducerContext, - input: RuntimeProfileWalletAdjustmentInput, - source_type: RuntimeProfileWalletLedgerSourceType, - consume: bool, -) -> Result { - let validated_input = build_runtime_profile_wallet_adjustment_input_with_metadata( - input.user_id, - input.amount, - input.ledger_id, - input.created_at_micros, - input.metadata_json, - ) - .map_err(|error| error.to_string())?; - let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); - let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount) - .map_err(|error| error.to_string())?; - if consume { - if !validated_input - .ledger_id - .starts_with(ASSET_OPERATION_CONSUME_LEDGER_PREFIX) - { - return Err("资产操作扣费流水缺少合法前缀".to_string()); - } - require_asset_operation_consume_unsettled( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - )?; - } else { - if !validated_input - .ledger_id - .starts_with(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - { - return Err("资产操作退款流水缺少合法前缀".to_string()); - } - match resolve_asset_operation_refund_disposition_from_ledger( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - )? { - AssetOperationRefundDisposition::Apply => {} - AssetOperationRefundDisposition::RecordIntent => { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - return get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ); - } - AssetOperationRefundDisposition::Noop => { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - return get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ); - } - } - } - let amount_delta = if consume { - -unsigned_delta - } else { - unsigned_delta - }; - - apply_profile_wallet_signed_delta( - ctx, - &validated_input.user_id, - amount_delta, - source_type, - &validated_input.ledger_id, - created_at, - true, - &validated_input.metadata_json, - )?; - if !consume { - record_asset_operation_wallet_settlement( - ctx, - &validated_input.user_id, - unsigned_delta, - &validated_input.ledger_id, - created_at, - )?; - } - get_profile_dashboard_snapshot( - ctx, - RuntimeProfileDashboardGetInput { - user_id: validated_input.user_id, - }, - ) -} - -pub(crate) fn settle_external_generation_attempt_refund( - ctx: &ReducerContext, - job_id: &str, - attempt: u32, - user_id: &str, - amount: u64, - settled_at: Timestamp, - settlement_reason: &str, -) -> Result, String> { - if amount == 0 { - return Ok(None); - } - let refund_ledger_id = format!( - "{ASSET_OPERATION_REFUND_LEDGER_PREFIX}external_generation_job:{}:attempt:{attempt}", - job_id.trim() - ); - apply_profile_wallet_adjustment( - ctx, - RuntimeProfileWalletAdjustmentInput { - user_id: user_id.to_string(), - amount, - ledger_id: refund_ledger_id.clone(), - created_at_micros: settled_at.to_micros_since_unix_epoch(), - metadata_json: serde_json::json!({ - "externalGenerationJobId": job_id.trim(), - "claimAttempt": attempt, - "settlementReason": settlement_reason, - }) - .to_string(), - }, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - false, - )?; - Ok(Some(refund_ledger_id)) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AssetOperationRefundDisposition { - Apply, - RecordIntent, - Noop, -} - -fn resolve_asset_operation_refund_disposition_from_ledger( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - refund_ledger_id: &str, -) -> Result { - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - let existing_refund = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&refund_ledger_id.to_string()); - let consume_ledger = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id); - let settlement = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id); - resolve_asset_operation_refund_disposition( - user_id, - amount, - refund_ledger_id, - settlement.as_ref(), - existing_refund.as_ref(), - consume_ledger.as_ref(), - ) -} - -fn resolve_asset_operation_refund_disposition( - user_id: &str, - amount: i64, - refund_ledger_id: &str, - settlement: Option<&AssetOperationWalletSettlement>, - existing_refund: Option<&ProfileWalletLedger>, - consume_ledger: Option<&ProfileWalletLedger>, -) -> Result { - if amount <= 0 { - return Err("资产操作退款金额必须大于 0".to_string()); - } - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - - if let Some(settlement) = settlement { - validate_asset_operation_wallet_settlement( - settlement, - &consume_ledger_id, - refund_ledger_id, - user_id, - amount, - )?; - if let Some(existing_refund) = existing_refund { - validate_asset_operation_wallet_ledger( - existing_refund, - refund_ledger_id, - user_id, - amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - "退款", - )?; - } - return Ok(AssetOperationRefundDisposition::Noop); - } - - if let Some(existing_refund) = existing_refund { - validate_asset_operation_wallet_ledger( - existing_refund, - refund_ledger_id, - user_id, - amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, - "退款", - )?; - return Ok(AssetOperationRefundDisposition::Noop); - } - - let Some(consume_ledger) = consume_ledger else { - return Ok(AssetOperationRefundDisposition::RecordIntent); - }; - validate_asset_operation_wallet_ledger( - consume_ledger, - &consume_ledger_id, - user_id, - -amount, - RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, - "扣费", - )?; - Ok(AssetOperationRefundDisposition::Apply) -} - -fn require_asset_operation_consume_unsettled( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - consume_ledger_id: &str, -) -> Result<(), String> { - if amount <= 0 { - return Err("资产操作扣费金额必须大于 0".to_string()); - } - let refund_ledger_id = asset_operation_refund_ledger_id(consume_ledger_id)?; - let Some(settlement) = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id.to_string()) - else { - return Ok(()); - }; - validate_asset_operation_wallet_settlement( - &settlement, - consume_ledger_id, - &refund_ledger_id, - user_id, - amount, - )?; - Err("资产操作扣费已结算,拒绝迟到或重复执行".to_string()) -} - -fn record_asset_operation_wallet_settlement( - ctx: &ReducerContext, - user_id: &str, - amount: i64, - refund_ledger_id: &str, - settled_at: Timestamp, -) -> Result<(), String> { - if amount <= 0 { - return Err("资产操作退款金额必须大于 0".to_string()); - } - let amount = u64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?; - let consume_ledger_id = asset_operation_consume_ledger_id(refund_ledger_id)?; - if let Some(existing) = ctx - .db - .asset_operation_wallet_settlement() - .consume_ledger_id() - .find(&consume_ledger_id) - { - return validate_asset_operation_wallet_settlement( - &existing, - &consume_ledger_id, - refund_ledger_id, - user_id, - i64::try_from(amount).map_err(|_| "资产操作退款金额超出范围".to_string())?, - ); - } - ctx.db - .asset_operation_wallet_settlement() - .insert(AssetOperationWalletSettlement { - consume_ledger_id, - refund_ledger_id: refund_ledger_id.to_string(), - user_id: user_id.to_string(), - amount, - settled_at, - }); - Ok(()) -} - -fn validate_asset_operation_wallet_settlement( - settlement: &AssetOperationWalletSettlement, - expected_consume_ledger_id: &str, - expected_refund_ledger_id: &str, - expected_user_id: &str, - expected_amount: i64, -) -> Result<(), String> { - let expected_amount = - u64::try_from(expected_amount).map_err(|_| "资产操作结算金额必须大于 0".to_string())?; - if expected_amount == 0 { - return Err("资产操作结算金额必须大于 0".to_string()); - } - if settlement.consume_ledger_id != expected_consume_ledger_id { - return Err("资产操作结算扣费流水 ID 不匹配".to_string()); - } - if settlement.refund_ledger_id != expected_refund_ledger_id { - return Err("资产操作结算退款流水 ID 不匹配".to_string()); - } - if settlement.user_id != expected_user_id { - return Err("资产操作结算用户不匹配".to_string()); - } - if settlement.amount != expected_amount { - return Err("资产操作结算金额不匹配".to_string()); - } - Ok(()) -} - -fn asset_operation_consume_ledger_id(refund_ledger_id: &str) -> Result { - refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - .ok_or_else(|| format!("资产操作退款流水 {refund_ledger_id} 缺少合法前缀")) -} - -fn asset_operation_refund_ledger_id(consume_ledger_id: &str) -> Result { - consume_ledger_id - .strip_prefix(ASSET_OPERATION_CONSUME_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_REFUND_LEDGER_PREFIX}{suffix}")) - .ok_or_else(|| format!("资产操作扣费流水 {consume_ledger_id} 缺少合法前缀")) -} - -fn validate_asset_operation_wallet_ledger( - ledger: &ProfileWalletLedger, - expected_ledger_id: &str, - expected_user_id: &str, - expected_amount_delta: i64, - expected_source_type: RuntimeProfileWalletLedgerSourceType, - label: &str, -) -> Result<(), String> { - if ledger.wallet_ledger_id != expected_ledger_id { - return Err(format!("资产操作{label}流水 ID 不匹配")); - } - if ledger.user_id != expected_user_id { - return Err(format!("资产操作{label}流水用户不匹配")); - } - if ledger.amount_delta != expected_amount_delta { - return Err(format!("资产操作{label}流水金额不匹配")); - } - if ledger.source_type != expected_source_type { - return Err(format!("资产操作{label}流水来源不匹配")); - } - Ok(()) -} - -fn consume_profile_membership_cycle_points( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - consumed_at: Timestamp, -) -> MembershipCyclePointMutation { - if amount == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return MembershipCyclePointMutation::none(); - }; - if !active_membership_row_at(&row, consumed_at) || row.cycle_remaining_points == 0 { - return MembershipCyclePointMutation::none(); - } - - let consumed = row.cycle_remaining_points.min(amount); - let cycle_resets_at_micros = row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()); - row.cycle_remaining_points -= consumed; - row.updated_at = consumed_at; - upsert_profile_membership_row(ctx, row); - MembershipCyclePointMutation { - points: consumed, - cycle_resets_at_micros, - } -} - -fn consume_profile_daily_free_points( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - consumed_at: Timestamp, -) -> DailyFreePointMutation { - if amount == 0 { - return DailyFreePointMutation::none(); - } - let day_key = runtime_profile_beijing_day_key(consumed_at.to_micros_since_unix_epoch()); - let Some(mut row) = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - else { - return DailyFreePointMutation::none(); - }; - if row.day_key != day_key || row.remaining_points == 0 { - return DailyFreePointMutation::none(); - } - - let consumed = row.remaining_points.min(amount); - row.remaining_points -= consumed; - row.updated_at = consumed_at; - ctx.db.profile_daily_free_points().user_id().update(row); - DailyFreePointMutation { - points: consumed, - day_key: Some(day_key), - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DailyFreeRefundRestorePlan { - restored_points: u64, - target_day_key: i64, - granted_points_delta: u64, -} - -fn resolve_daily_free_refund_restore_plan( - consumed_day_key: i64, - current_day_key: i64, - row_day_key: i64, - granted_points: u64, - remaining_points: u64, - candidate_points: u64, - refund_amount: u64, -) -> Option { - if candidate_points == 0 || refund_amount == 0 || row_day_key != current_day_key { - return None; - } - - let candidate_points = candidate_points.min(refund_amount); - if consumed_day_key == current_day_key { - let restored_points = candidate_points.min(granted_points.saturating_sub(remaining_points)); - return (restored_points > 0).then_some(DailyFreeRefundRestorePlan { - restored_points, - target_day_key: current_day_key, - granted_points_delta: 0, - }); - } - if consumed_day_key > current_day_key { - return None; - } - - Some(DailyFreeRefundRestorePlan { - restored_points: candidate_points, - target_day_key: current_day_key, - granted_points_delta: candidate_points, - }) -} - -fn restore_profile_daily_free_points_for_refund( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - refund_ledger_id: &str, - refunded_at: Timestamp, -) -> DailyFreePointMutation { - if amount == 0 { - return DailyFreePointMutation::none(); - } - let Some(consume_ledger_id) = refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - else { - return DailyFreePointMutation::none(); - }; - let Some(consume_ledger) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id) - else { - return DailyFreePointMutation::none(); - }; - let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else { - return DailyFreePointMutation::none(); - }; - let restore_candidate = - daily_free_refund_restore_candidate_from_consume_metadata(metadata_json); - let Some(expected_day_key) = restore_candidate.day_key else { - return DailyFreePointMutation::none(); - }; - let current_day_key = runtime_profile_beijing_day_key(refunded_at.to_micros_since_unix_epoch()); - - let Some(mut row) = ctx - .db - .profile_daily_free_points() - .user_id() - .find(&user_id.to_string()) - else { - return DailyFreePointMutation::none(); - }; - let Some(plan) = resolve_daily_free_refund_restore_plan( - expected_day_key, - current_day_key, - row.day_key, - row.granted_points, - row.remaining_points, - restore_candidate.points, - amount, - ) else { - return DailyFreePointMutation::none(); - }; - - row.granted_points = row.granted_points.saturating_add(plan.granted_points_delta); - row.remaining_points = row.remaining_points.saturating_add(plan.restored_points); - row.updated_at = refunded_at; - ctx.db.profile_daily_free_points().user_id().update(row); - DailyFreePointMutation { - points: plan.restored_points, - day_key: Some(plan.target_day_key), - } -} - -fn restore_profile_membership_cycle_points_for_refund( - ctx: &ReducerContext, - user_id: &str, - amount: u64, - refund_ledger_id: &str, - refunded_at: Timestamp, -) -> MembershipCyclePointMutation { - if amount == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(consume_ledger_id) = refund_ledger_id - .strip_prefix(ASSET_OPERATION_REFUND_LEDGER_PREFIX) - .map(|suffix| format!("{ASSET_OPERATION_CONSUME_LEDGER_PREFIX}{suffix}")) - else { - return MembershipCyclePointMutation::none(); - }; - let Some(consume_ledger) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&consume_ledger_id) - else { - return MembershipCyclePointMutation::none(); - }; - let Some(metadata_json) = consume_ledger.metadata_json.as_deref() else { - return MembershipCyclePointMutation::none(); - }; - let restore_candidate = - membership_refund_restore_candidate_from_consume_metadata(metadata_json); - if restore_candidate.points == 0 { - return MembershipCyclePointMutation::none(); - } - let Some(expected_cycle_resets_at_micros) = restore_candidate.cycle_resets_at_micros else { - return MembershipCyclePointMutation::none(); - }; - - let Some(mut row) = ctx - .db - .profile_membership() - .user_id() - .find(&user_id.to_string()) - else { - return MembershipCyclePointMutation::none(); - }; - if !active_membership_row_at(&row, refunded_at) { - return MembershipCyclePointMutation::none(); - } - if row - .cycle_resets_at - .map(|value| value.to_micros_since_unix_epoch()) - != Some(expected_cycle_resets_at_micros) - { - return MembershipCyclePointMutation::none(); - } - - let available_restore_room = row - .cycle_granted_points - .saturating_sub(row.cycle_remaining_points); - let restored = restore_candidate - .points - .min(amount) - .min(available_restore_room); - if restored == 0 { - return MembershipCyclePointMutation::none(); - } - - row.cycle_remaining_points = row.cycle_remaining_points.saturating_add(restored); - row.updated_at = refunded_at; - upsert_profile_membership_row(ctx, row); - MembershipCyclePointMutation { - points: restored, - cycle_resets_at_micros: Some(expected_cycle_resets_at_micros), - } -} - -fn membership_refund_restore_candidate_from_consume_metadata( - metadata_json: &str, -) -> MembershipCyclePointMutation { - let Some(metadata) = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - else { - return MembershipCyclePointMutation::none(); - }; - let membership_period_delta = metadata - .get("membershipPeriodPointsDelta") - .and_then(JsonValue::as_i64) - .unwrap_or(0); - if membership_period_delta >= 0 { - return MembershipCyclePointMutation::none(); - } - MembershipCyclePointMutation { - points: membership_period_delta.unsigned_abs(), - cycle_resets_at_micros: metadata - .get("cycleResetsAtMicros") - .and_then(JsonValue::as_i64), - } -} - -fn daily_free_refund_restore_candidate_from_consume_metadata( - metadata_json: &str, -) -> DailyFreePointMutation { - let Some(metadata) = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - else { - return DailyFreePointMutation::none(); - }; - let daily_free_delta = metadata - .get("dailyFreePointsDelta") - .and_then(JsonValue::as_i64) - .unwrap_or(0); - if daily_free_delta >= 0 { - return DailyFreePointMutation::none(); - } - DailyFreePointMutation { - points: daily_free_delta.unsigned_abs(), - day_key: metadata.get("dailyFreeDayKey").and_then(JsonValue::as_i64), - } -} - -fn metadata_with_profile_wallet_delta_split( - metadata_json: &str, - daily_free_delta: i64, - membership_period_delta: i64, - permanent_delta: i64, - daily_free_day_key: Option, - cycle_resets_at_micros: Option, -) -> String { - let mut metadata = serde_json::from_str::(metadata_json) - .ok() - .filter(JsonValue::is_object) - .unwrap_or_else(|| json!({})); - if let Some(object) = metadata.as_object_mut() { - object.insert("dailyFreePointsDelta".to_string(), json!(daily_free_delta)); - object.insert( - "membershipPeriodPointsDelta".to_string(), - json!(membership_period_delta), - ); - object.insert("permanentPointsDelta".to_string(), json!(permanent_delta)); - if let Some(daily_free_day_key) = daily_free_day_key { - object.insert("dailyFreeDayKey".to_string(), json!(daily_free_day_key)); - } - if let Some(cycle_resets_at_micros) = cycle_resets_at_micros { - object.insert( - "cycleResetsAtMicros".to_string(), - json!(cycle_resets_at_micros), - ); - } - } - serde_json::to_string(&metadata).unwrap_or_else(|_| metadata_json.to_string()) -} - -fn metadata_with_profile_wallet_consumption_split( - metadata_json: &str, - total_consumed: u64, - daily_free_consumed: DailyFreePointMutation, - membership_period_consumed: MembershipCyclePointMutation, -) -> String { - if total_consumed == 0 { - return metadata_json.to_string(); - } - let permanent_consumed = total_consumed - .saturating_sub(daily_free_consumed.points) - .saturating_sub(membership_period_consumed.points); - metadata_with_profile_wallet_delta_split( - metadata_json, - -(daily_free_consumed.points as i64), - -(membership_period_consumed.points as i64), - -(permanent_consumed as i64), - daily_free_consumed.day_key, - membership_period_consumed.cycle_resets_at_micros, - ) -} - -fn metadata_with_profile_wallet_refund_split( - metadata_json: &str, - total_refunded: u64, - daily_free_refunded: DailyFreePointMutation, - membership_period_refunded: MembershipCyclePointMutation, -) -> String { - if total_refunded == 0 { - return metadata_json.to_string(); - } - let permanent_refunded = total_refunded - .saturating_sub(daily_free_refunded.points) - .saturating_sub(membership_period_refunded.points); - metadata_with_profile_wallet_delta_split( - metadata_json, - daily_free_refunded.points as i64, - membership_period_refunded.points as i64, - permanent_refunded as i64, - daily_free_refunded.day_key, - membership_period_refunded.cycle_resets_at_micros, - ) -} - -fn apply_profile_wallet_signed_delta( - ctx: &ReducerContext, - user_id: &str, - amount_delta: i64, - source_type: RuntimeProfileWalletLedgerSourceType, - ledger_id: &str, - created_at: Timestamp, - idempotent: bool, - metadata_json: &str, -) -> Result { - let settled_at = ctx.timestamp; - let ledger_recorded_at = profile_wallet_ledger_recorded_at(created_at, settled_at); - refresh_profile_wallet_expiring_points(ctx, user_id, settled_at); - if idempotent { - if let Some(existing) = ctx - .db - .profile_wallet_ledger() - .wallet_ledger_id() - .find(&ledger_id.to_string()) - { - validate_idempotent_profile_wallet_ledger( - &existing, - user_id, - amount_delta, - source_type, - )?; - return Ok(profile_wallet_balance(ctx, user_id)); - } - } - if amount_delta < 0 { - validate_runtime_profile_wallet_debit_restrictions( - amount_delta, - source_type, - has_profile_wallet_manual_restriction(ctx, user_id), - has_profile_recharge_refund_wallet_freeze(ctx, user_id), - )?; - } - - let current = ctx - .db - .profile_dashboard_state() - .user_id() - .find(&user_id.to_string()); - let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - if amount_delta < 0 - && source_type != RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery - { - validate_runtime_profile_wallet_debit_availability( - previous_balance, - active_profile_recharge_refund_hold_points(ctx, user_id), - amount_delta.unsigned_abs(), - )?; - } - let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta) - .map_err(|error| error.to_string())?; - let created_state_at = current - .as_ref() - .map(|row| row.created_at) - .unwrap_or(ledger_recorded_at); - let daily_free_consumed = if amount_delta < 0 - && !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) { - consume_profile_daily_free_points(ctx, user_id, amount_delta.unsigned_abs(), settled_at) - } else { - DailyFreePointMutation::none() - }; - let membership_period_consumed = if amount_delta < 0 - && !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) { - consume_profile_membership_cycle_points( - ctx, - user_id, - amount_delta - .unsigned_abs() - .saturating_sub(daily_free_consumed.points), - settled_at, - ) - } else { - MembershipCyclePointMutation::none() - }; - let daily_free_refunded = if amount_delta > 0 - && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund - { - restore_profile_daily_free_points_for_refund( - ctx, - user_id, - amount_delta as u64, - ledger_id, - settled_at, - ) - } else { - DailyFreePointMutation::none() - }; - let membership_period_refunded = if amount_delta > 0 - && source_type == RuntimeProfileWalletLedgerSourceType::AssetOperationRefund - { - restore_profile_membership_cycle_points_for_refund( - ctx, - user_id, - (amount_delta as u64).saturating_sub(daily_free_refunded.points), - ledger_id, - settled_at, - ) - } else { - MembershipCyclePointMutation::none() - }; - let ledger_metadata_json = if amount_delta < 0 { - metadata_with_profile_wallet_consumption_split( - metadata_json, - amount_delta.unsigned_abs(), - daily_free_consumed, - membership_period_consumed, - ) - } else if amount_delta > 0 { - metadata_with_profile_wallet_refund_split( - metadata_json, - amount_delta as u64, - daily_free_refunded, - membership_period_refunded, - ) - } else { - metadata_json.to_string() - }; - - if let Some(existing) = current { - ctx.db - .profile_dashboard_state() - .user_id() - .delete(&existing.user_id); - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: existing.total_play_time_ms, - created_at: existing.created_at, - updated_at: ledger_recorded_at, - }); - } else { - ctx.db - .profile_dashboard_state() - .insert(ProfileDashboardState { - user_id: user_id.to_string(), - wallet_balance: next_balance, - total_play_time_ms: 0, - created_at: created_state_at, - updated_at: ledger_recorded_at, - }); - } - - ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { - wallet_ledger_id: ledger_id.to_string(), - user_id: user_id.to_string(), - amount_delta, - balance_after: next_balance, - source_type, - created_at: ledger_recorded_at, - metadata_json: Some(ledger_metadata_json), - }); - - if amount_delta > 0 { - repay_profile_recharge_refund_debt_from_permanent_points(ctx, user_id); - } - Ok(profile_wallet_balance(ctx, user_id)) -} - -fn profile_wallet_ledger_recorded_at( - _business_event_at: Timestamp, - settled_at: Timestamp, -) -> Timestamp { - settled_at -} - -fn validate_idempotent_profile_wallet_ledger( - existing: &ProfileWalletLedger, - expected_user_id: &str, - expected_amount_delta: i64, - expected_source_type: RuntimeProfileWalletLedgerSourceType, -) -> Result<(), String> { - if existing.user_id != expected_user_id { - return Err("钱包幂等流水用户不匹配".to_string()); - } - if existing.amount_delta != expected_amount_delta { - return Err("钱包幂等流水金额不匹配".to_string()); - } - if existing.source_type != expected_source_type { - return Err("钱包幂等流水来源不匹配".to_string()); - } - Ok(()) -} - -fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && profile_recharge_order_counts_as_paid_purchase(&row) - }) -} - -fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool { - ctx.db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && row.product_id == product_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && profile_recharge_order_counts_as_paid_purchase(&row) - }) -} - -fn profile_recharge_order_counts_as_paid_purchase(order: &ProfileRechargeOrder) -> bool { - order.paid_at.is_some() -} - -fn has_profile_recharge_refund_wallet_freeze(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_recharge_order_refund_settlement() - .by_profile_recharge_order_refund_settlement_user_id() - .filter(user_id) - .any(|row| profile_recharge_refund_settlement_freezes_wallet(&row)) -} - -fn profile_recharge_refund_settlement_freezes_wallet( - settlement: &ProfileRechargeOrderRefundSettlement, -) -> bool { - settlement.wallet_frozen || settlement.unrecovered_points > 0 -} - -fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db - .profile_wallet_ledger() - .by_profile_wallet_ledger_user_id() - .filter(user_id) - .any(|row| { - row.user_id == user_id - && profile_wallet_ledger_source_blocks_legacy_snapshot_sync(row.source_type) - }) -} - -fn profile_wallet_ledger_source_blocks_legacy_snapshot_sync( - source_type: RuntimeProfileWalletLedgerSourceType, -) -> bool { - !matches!( - source_type, - RuntimeProfileWalletLedgerSourceType::SnapshotSync - | RuntimeProfileWalletLedgerSourceType::DailyFreeGrant - | RuntimeProfileWalletLedgerSourceType::DailyFreeReset - ) -} - -fn merge_legacy_wallet_balance_with_daily_free_points( - legacy_balance: u64, - daily_free_remaining_points: u64, -) -> u64 { - legacy_balance.saturating_add(daily_free_remaining_points) -} - -fn latest_profile_recharge_order( - ctx: &ReducerContext, - user_id: &str, -) -> Option { - let mut orders = ctx - .db - .profile_recharge_order() - .by_profile_recharge_order_user_id() - .filter(user_id) - .collect::>(); - orders.sort_by(|left, right| { - right - .created_at - .to_micros_since_unix_epoch() - .cmp(&left.created_at.to_micros_since_unix_epoch()) - .then_with(|| left.order_id.cmp(&right.order_id)) - }); - orders.into_iter().next() -} - -fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_id: &str) -> u32 { - ctx.db - .profile_redeem_code_usage() - .by_profile_redeem_code_usage_code_user_id() - .filter((code, user_id)) - .count() as u32 -} - -fn resolve_profile_redeem_code_allowed_user_ids( - ctx: &ReducerContext, - input: &RuntimeProfileRedeemCodeAdminUpsertInput, -) -> Result, String> { - if input.mode != RuntimeProfileRedeemCodeMode::Private { - return Ok(Vec::new()); - } - - let mut allowed_user_ids = input.allowed_user_ids.clone(); - for public_user_code in &input.allowed_public_user_codes { - if let Some(account) = ctx - .db - .user_account() - .by_user_account_public_code() - .filter(public_user_code) - .next() - { - allowed_user_ids.push(account.user_id); - } - } - allowed_user_ids.sort(); - allowed_user_ids.dedup(); - - if allowed_user_ids.is_empty() { - return Err("私有兑换码必须指定可兑换用户".to_string()); - } - - Ok(allowed_user_ids) -} - -fn build_profile_redeem_code_snapshot_from_row( - row: &ProfileRedeemCode, -) -> RuntimeProfileRedeemCodeSnapshot { - RuntimeProfileRedeemCodeSnapshot { - code: row.code.clone(), - mode: row.mode, - reward_points: row.reward_points, - max_uses: row.max_uses, - global_used_count: row.global_used_count, - enabled: row.enabled, - allowed_user_ids: row.allowed_user_ids.clone(), - created_by: row.created_by.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - starts_at_micros: row - .starts_at - .map(|value| value.to_micros_since_unix_epoch()), - expires_at_micros: row - .expires_at - .map(|value| value.to_micros_since_unix_epoch()), - } -} - -fn build_profile_invite_code_snapshot_from_row( - row: &ProfileInviteCode, -) -> RuntimeProfileInviteCodeSnapshot { - RuntimeProfileInviteCodeSnapshot { - user_id: row.user_id.clone(), - invite_code: row.invite_code.clone(), - metadata_json: row.metadata_json.clone(), - starts_at_micros: row - .starts_at - .map(|value| value.to_micros_since_unix_epoch()), - expires_at_micros: row - .expires_at - .map(|value| value.to_micros_since_unix_epoch()), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_code_operation_snapshot_from_row( - row: &ProfileCodeOperation, -) -> RuntimeProfileCodeOperationSnapshot { - RuntimeProfileCodeOperationSnapshot { - operation_id: row.operation_id.clone(), - code_kind: row.code_kind.clone(), - code: row.code.clone(), - action: row.action.clone(), - operator_user_id: row.operator_user_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_wallet_ledger_snapshot_from_row( - row: &ProfileWalletLedger, -) -> RuntimeProfileWalletLedgerEntrySnapshot { - RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: row.wallet_ledger_id.clone(), - user_id: row.user_id.clone(), - amount_delta: row.amount_delta, - balance_after: row.balance_after, - source_type: row.source_type, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - metadata_json: row - .metadata_json - .clone() - .unwrap_or_else(|| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()), - } -} - -fn build_profile_wallet_config_snapshot_from_row( - row: &ProfileWalletConfig, -) -> RuntimeProfileWalletConfigSnapshot { - RuntimeProfileWalletConfigSnapshot { - config_id: row.config_id.clone(), - initial_mud_points: row.initial_mud_points, - 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_task_config_snapshot_from_row( - row: &ProfileTaskConfig, -) -> RuntimeProfileTaskConfigSnapshot { - RuntimeProfileTaskConfigSnapshot { - task_id: row.task_id.clone(), - title: row.title.clone(), - description: row.description.clone(), - event_key: row.event_key.clone(), - cycle: row.cycle, - scope_kind: row.scope_kind, - threshold: row.threshold, - reward_points: row.reward_points, - 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_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, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, - 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, - membership_period_points: row.membership_period_points, - membership_period_days: row.membership_period_days, - membership_queue_limit: row.membership_queue_limit, - membership_discount_bps: row.membership_discount_bps, - } -} - -fn build_profile_recharge_order_snapshot_from_row( - row: &ProfileRechargeOrder, -) -> RuntimeProfileRechargeOrderSnapshot { - RuntimeProfileRechargeOrderSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - product_id: row.product_id.clone(), - product_title: row.product_title.clone(), - kind: row.kind, - amount_cents: row.amount_cents, - status: row.status, - payment_channel: row.payment_channel.clone(), - paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), - provider_transaction_id: row.provider_transaction_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - points_delta: row.points_delta, - membership_expires_at_micros: row - .membership_expires_at - .map(|value| value.to_micros_since_unix_epoch()), - expired_at_micros: row - .expired_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_checked_at_micros: row - .expiration_checked_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_provider_state: row.expiration_provider_state.clone(), - expiration_last_error: row.expiration_last_error.clone(), - } -} - -fn build_profile_recharge_refund_snapshot_from_row( - row: &ProfileRechargeRefund, -) -> RuntimeProfileRechargeRefundSnapshot { - RuntimeProfileRechargeRefundSnapshot { - out_refund_no: row.out_refund_no.clone(), - provider_refund_id: row.provider_refund_id.clone(), - order_id: row.order_id.clone(), - provider_transaction_id: row.provider_transaction_id.clone(), - user_id: row.user_id.clone(), - provider_status: row.provider_status, - total_cents: row.total_cents, - refund_cents: row.refund_cents, - payer_total_cents: row.payer_total_cents, - payer_refund_cents: row.payer_refund_cents, - success_at_micros: row - .success_at - .map(|value| value.to_micros_since_unix_epoch()), - first_observed_at_micros: row.first_observed_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - last_observation_source: row.last_observation_source, - last_observation_id: row.last_observation_id.clone(), - order_settled_at_micros: row - .order_settled_at - .map(|value| value.to_micros_since_unix_epoch()), - target_recovery_points: row.target_recovery_points, - recovered_points: row.recovered_points, - unrecovered_points: row.unrecovered_points, - recovery_status: row.recovery_status, - last_recovery_ledger_id: row.last_recovery_ledger_id.clone(), - last_error_code: row.last_error_code.clone(), - manual_review_resolved_by_admin_user_id: row - .manual_review_resolved_by_admin_user_id - .clone(), - manual_review_resolution_reason: row.manual_review_resolution_reason.clone(), - manual_review_resolved_at_micros: row - .manual_review_resolved_at - .map(|value| value.to_micros_since_unix_epoch()), - manual_review_resolved_error_code: row.manual_review_resolved_error_code.clone(), - } -} - -fn build_profile_recharge_refund_hold_snapshot_from_row( - row: &ProfileRechargeRefundHold, -) -> RuntimeProfileRechargeRefundHoldSnapshot { - RuntimeProfileRechargeRefundHoldSnapshot { - out_refund_no: row.out_refund_no.clone(), - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - refund_cents: row.refund_cents, - held_points: row.held_points, - status: row.status, - admin_user_id: row.admin_user_id.clone(), - reason: row.reason.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - settled_at_micros: row - .settled_at - .map(|value| value.to_micros_since_unix_epoch()), - released_at_micros: row - .released_at - .map(|value| value.to_micros_since_unix_epoch()), - released_by_admin_user_id: row.released_by_admin_user_id.clone(), - release_reason: row.release_reason.clone(), - } -} - -fn build_profile_wallet_manual_restriction_snapshot_from_row( - row: &ProfileWalletManualRestriction, -) -> RuntimeProfileWalletManualRestrictionSnapshot { - RuntimeProfileWalletManualRestrictionSnapshot { - user_id: row.user_id.clone(), - frozen: row.frozen, - reason: row.reason.clone(), - created_by_admin_user_id: row.created_by_admin_user_id.clone(), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_by_admin_user_id: row.updated_by_admin_user_id.clone(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -#[cfg(test)] -fn build_profile_recharge_refund_observation_snapshot_from_row( - row: &ProfileRechargeRefundObservation, -) -> RuntimeProfileRechargeRefundObservationSnapshot { - RuntimeProfileRechargeRefundObservationSnapshot { - observation_id: row.observation_id.clone(), - out_refund_no: row.out_refund_no.clone(), - provider_refund_id: row.provider_refund_id.clone(), - order_id: row.order_id.clone(), - provider_transaction_id: row.provider_transaction_id.clone(), - source: row.source, - provider_status: row.provider_status, - total_cents: row.total_cents, - refund_cents: row.refund_cents, - payer_total_cents: row.payer_total_cents, - payer_refund_cents: row.payer_refund_cents, - success_at_micros: row - .success_at - .map(|value| value.to_micros_since_unix_epoch()), - notification_ref: row.notification_ref.clone(), - payload_fingerprint: row.payload_fingerprint.clone(), - resolution_code: row.resolution_code.clone(), - observed_at_micros: row.observed_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_order_refund_settlement_snapshot_from_row( - row: &ProfileRechargeOrderRefundSettlement, -) -> RuntimeProfileRechargeOrderRefundSettlementSnapshot { - RuntimeProfileRechargeOrderRefundSettlementSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - successful_refund_count: row.successful_refund_count, - cumulative_success_refund_cents: row.cumulative_success_refund_cents, - target_recovery_points: row.target_recovery_points, - recovered_points: row.recovered_points, - unrecovered_points: row.unrecovered_points, - recovery_status: row.recovery_status, - wallet_frozen: row.wallet_frozen, - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_refund_bill_checkpoint_snapshot_from_row( - row: &ProfileRechargeRefundBillCheckpoint, -) -> RuntimeProfileRechargeRefundBillCheckpointSnapshot { - RuntimeProfileRechargeRefundBillCheckpointSnapshot { - checkpoint_id: row.checkpoint_id.clone(), - bill_date: row.bill_date.clone(), - bill_hash: row.bill_hash.clone(), - processed_refund_count: row.processed_refund_count, - completed_at_micros: row.completed_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_recharge_order_expiration_schedule_snapshot_from_row( - row: &ProfileRechargeOrderExpirationSchedule, -) -> RuntimeProfileRechargeOrderExpirationScheduleSnapshot { - RuntimeProfileRechargeOrderExpirationScheduleSnapshot { - order_id: row.order_id.clone(), - user_id: row.user_id.clone(), - scheduled_at_micros: row.scheduled_at.to_micros_since_unix_epoch(), - lease_owner: row.lease_owner.clone(), - lease_expires_at_micros: row - .lease_expires_at - .map(|value| value.to_micros_since_unix_epoch()), - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_feedback_submission_snapshot_from_row( - row: &ProfileFeedbackSubmission, -) -> RuntimeProfileFeedbackSubmissionSnapshot { - RuntimeProfileFeedbackSubmissionSnapshot { - feedback_id: row.feedback_id.clone(), - user_id: row.user_id.clone(), - description: row.description.clone(), - contact_phone: row.contact_phone.clone(), - evidence_json: row.evidence_json.clone(), - status: row.status, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } -} - -fn build_profile_played_world_snapshot_from_row( - row: &ProfilePlayedWorld, -) -> RuntimeProfilePlayedWorldSnapshot { - RuntimeProfilePlayedWorldSnapshot { - played_world_id: row.played_world_id.clone(), - user_id: row.user_id.clone(), - world_key: row.world_key.clone(), - owner_user_id: row.owner_user_id.clone(), - profile_id: row.profile_id.clone(), - world_type: row.world_type.clone(), - world_title: row.world_title.clone(), - world_subtitle: row.world_subtitle.clone(), - first_played_at_micros: row.first_played_at.to_micros_since_unix_epoch(), - last_played_at_micros: row.last_played_at.to_micros_since_unix_epoch(), - last_observed_play_time_ms: row.last_observed_play_time_ms, - } -} diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 92b947c88..9638e2504 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react'; import { @@ -339,19 +339,18 @@ describe('ImageCanvasEditorView', () => { updatedAt: null, }); getPlatformProfileRechargeCenterMock.mockResolvedValue({ - walletBalance: 1234, mudPointBalance: { totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', }, membership: { status: 'normal', - tier: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -359,11 +358,12 @@ describe('ImageCanvasEditorView', () => { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 30, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }); @@ -635,10 +635,9 @@ describe('ImageCanvasEditorView', () => { usePlatformWalletStore .getState() .applyWalletBalanceSnapshot(walletSnapshot!, { - totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -663,7 +662,7 @@ describe('ImageCanvasEditorView', () => { , ); - expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy(); + expect(await screen.findByLabelText(/可用总额 1,234/)).toBeTruthy(); expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled(); }); @@ -703,17 +702,17 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); - it('shows the owner-matched legacy wallet total without inventing breakdown rows', async () => { + it('shows the breakdown read failure without inventing rows', async () => { const user = userEvent.setup(); usePlatformWalletStore.getState().setWalletOwner('user-1'); - const walletSnapshot = usePlatformWalletStore - .getState() - .captureWalletBalanceSnapshot('user-1'); - usePlatformWalletStore - .getState() - .applyLegacyWalletBalanceSnapshot(walletSnapshot!, 37); - getPlatformProfileRechargeCenterMock.mockResolvedValue({ - walletBalance: 37, + getPlatformProfileRechargeCenterMock.mockRejectedValue( + new Error('network down'), + ); + // 真实链路里 AuthGate 挂载 usePlatformWalletLifecycle 时会触发首次余额读取;本用例独立渲染 + // ImageCanvasEditorView,需手动补上这一步。否则 store 停在 idle,入口把它当成 loading 而 + // 跳过 hover 触发的明细请求,弹层会永远停在「余额读取中」。 + await act(async () => { + await usePlatformWalletStore.getState().onWalletBalanceMayHaveChanged(); }); render( { ); const walletButton = await screen.findByRole('button', { - name: '泥点 37', + name: /可用总额 --/, }); await user.hover(walletButton); const details = await screen.findByRole('dialog', { - name: '泥点账户详情', + name: '会员与泥点账户详情', }); - expect(within(details).getByText('泥点明细读取失败')).toBeTruthy(); + // 详情弹层打开即渲染 loading 态,读取失败的 rejection 在下一个微任务才提交; + // 必须等待错误文案出现,否则同步 getByText 会稳定命中的是「余额读取中」。 + expect(await within(details).findByText('泥点明细读取失败')).toBeTruthy(); expect(within(details).queryByText('充值中心响应缺少泥点余额')).toBeNull(); - expect(within(details).queryByText('不限时泥点')).toBeNull(); + expect(within(details).queryByText('永久泥点')).toBeNull(); expect(within(details).queryByText('每日免费泥点')).toBeNull(); }); @@ -771,7 +772,7 @@ describe('ImageCanvasEditorView', () => { ); expect( - (await screen.findByRole('button', { name: '泥点 --' })).getAttribute( + (await screen.findByRole('button', { name: /可用总额 --/ })).getAttribute( 'aria-busy', ), ).toBe('true'); @@ -786,10 +787,9 @@ describe('ImageCanvasEditorView', () => { usePlatformWalletStore .getState() .applyWalletBalanceSnapshot(walletSnapshot!, { - totalPoints: 1234, permanentPoints: 1000, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 234, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -815,17 +815,16 @@ describe('ImageCanvasEditorView', () => { ); const walletButton = await screen.findByRole('button', { - name: '泥点 1,234', + name: /可用总额 1,234/, }); await user.hover(walletButton); const details = await screen.findByRole('dialog', { - name: '泥点账户详情', + name: '会员与泥点账户详情', }); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).queryByText('限时泥点')).toBeNull(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect( @@ -919,11 +918,11 @@ describe('ImageCanvasEditorView', () => { const viewport = screen.getByLabelText('画布工作区'); fireEvent.click( await screen.findByRole('button', { - name: '充值', + name: '会员与泥点', }), ); expect( - await screen.findByRole('dialog', { name: '购买更多泥点' }), + await screen.findByRole('dialog', { name: '会员与泥点' }), ).toBeTruthy(); fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); @@ -949,7 +948,7 @@ describe('ImageCanvasEditorView', () => { ).toBe('true'); expect(viewport.getAttribute('aria-disabled')).toBe('true'); - fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); + fireEvent.click(screen.getByRole('button', { name: '关闭会员与泥点' })); const resumedToolbar = await screen.findByRole('toolbar', { name: '快速编辑框选工具', diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8aca4173e..ff65cfe2b 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -561,6 +561,7 @@ export function ImageCanvasEditorView({ buyRechargeProduct, closeNativeWechatPayment, confirmNativeWechatPayment, + isLoadingMembershipQuote, isLoadingRechargeCenter, isLoadingWalletLedger, isRechargeOpen, @@ -569,12 +570,15 @@ export function ImageCanvasEditorView({ isWalletLedgerOpen, loadRechargeCenter, loadWalletLedger, + membershipQuote, + membershipQuoteError, nativeWechatPayment, openRechargeOrRewardCodeModal, openWalletLedgerPanel, rechargeCenter, rechargeError, rechargePaymentResult, + requestMembershipQuote, rewardCodeError, rewardCodeInput, rewardCodeSuccess, @@ -584,6 +588,7 @@ export function ImageCanvasEditorView({ setRechargePaymentResult, setRewardCodeInput, submittingRechargeProductId, + submitMembershipCheckout, submitRewardCode, walletLedger, walletLedgerError, @@ -2522,7 +2527,9 @@ export function ImageCanvasEditorView({ walletBreakdown: mudPointBalance, isWalletBalanceLoading, isWalletDetailsLoading: isLoadingRechargeCenter, - walletDetailsError: rechargeError || mudPointBalanceError || null, + // 明细弹层展示的正是钱包 store 的 breakdown;其读取失败文案优先于充值弹层的原始报错, + // 避免把「读详情」的原始错误串(如 network down)泄漏到顶栏明细里。 + walletDetailsError: mudPointBalanceError || rechargeError || null, currentUser: authUi?.user, assetExportStatus, isExportingAssets, @@ -2746,9 +2753,14 @@ export function ImageCanvasEditorView({ error={rechargeError} submittingProductId={submittingRechargeProductId} nativePayment={nativeWechatPayment} + membershipQuote={membershipQuote} + isLoadingMembershipQuote={isLoadingMembershipQuote} + membershipQuoteError={membershipQuoteError} onClose={() => setIsRechargeOpen(false)} onRetry={loadRechargeCenter} onBuy={buyRechargeProduct} + onRequestMembershipQuote={requestMembershipQuote} + onSubmitMembershipCheckout={submitMembershipCheckout} onConfirmNativePayment={confirmNativeWechatPayment} onCloseNativePayment={closeNativeWechatPayment} /> diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index d8b2a1ff1..3d1c4928a 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -17,8 +17,8 @@ import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts'; const walletBreakdown = { totalPoints: 1_234, permanentPoints: 1_000, - limitedPoints: 214, - limitedExpiresAt: '2026-07-31T16:00:00Z', + monthlyPoints: 214, + monthlyResetsAt: '2026-07-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -105,27 +105,24 @@ describe('ImageCanvasTopbarView', () => { const user = userEvent.setup(); const props = renderTopbar({ walletBalance: 12_345, - walletBreakdown: { - ...walletBreakdown, - totalPoints: 12_345, - }, }); const walletChip = screen.getByRole('button', { - name: '泥点 12,345', + name: /可用总额 12,345/, }); - expect(walletChip.textContent).toBe('泥点 1.2万'); + expect(walletChip.textContent).toContain('可用总额 1.2万'); await user.hover(walletChip); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).queryByText('限时泥点')).toBeNull(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + const details = screen.getByRole('dialog', { + name: '会员与泥点账户详情', + }); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(props.onOpenWalletLedger).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: '充值' })); + await user.click(screen.getByRole('button', { name: '会员与泥点' })); expect(props.onRecharge).toHaveBeenCalledTimes(1); expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1); }); diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index acf9d4934..9b0efa1d4 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -77,8 +77,8 @@ describe('PlatformActiveProfileView', () => { mudPointBalance={{ totalPoints: 108, permanentPoints: 88, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index c271c9365..4ef7a27fa 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -16,7 +16,7 @@ import { useCallback, useRef, useState } from 'react'; import type { AuthUser, ProfileDashboardSummary, - ProfileMudPointBalance, + ProfileMudPointBalanceView, } from '@/packages/shared/src'; import profileClockImage from '../../../media/profile/_Image (1).png'; @@ -67,7 +67,7 @@ type PlatformActiveProfileViewProps = { isLoadingDashboard: boolean; isLoadingWalletBalance: boolean; legacyWalletBalance: number | null; - mudPointBalance: ProfileMudPointBalance | null; + mudPointBalance: ProfileMudPointBalanceView | null; onLogin: () => void; onOpenApiKeys: () => void; onOpenPaymentService?: () => void; @@ -250,7 +250,7 @@ function formatDashboardCount(value: number) { } function formatWalletBalance( - balance: ProfileMudPointBalance | null, + balance: ProfileMudPointBalanceView | null, legacyBalance: number | null, isLoading: boolean, ) { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index e515f58c9..6240e4b9f 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -44,7 +44,13 @@ const profileClientMock = vi.hoisted(() => ({ const profileCenterMock = vi.hoisted(() => ({ isWalletLedgerOpen: false, - rechargeCenter: null as { walletBalance: number } | null, + rechargeCenter: null as { + mudPointBalance?: { + dailyFreePoints: number; + monthlyPoints: number; + permanentPoints: number; + }; + } | null, })); const gameDistributionMock = vi.hoisted(() => ({ @@ -262,12 +268,10 @@ describe('PlatformEntryActiveFlowShell', () => { updatedAt: null, }); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 888, mudPointBalance: { - totalPoints: 207, permanentPoints: 180, - limitedPoints: 7, - limitedExpiresAt: '2026-08-31T16:00:00Z', + monthlyPoints: 7, + monthlyResetsAt: '2026-08-31T16:00:00Z', dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -281,7 +285,7 @@ describe('PlatformEntryActiveFlowShell', () => { />, ); - expect(await screen.findByLabelText('泥点 207')).toBeTruthy(); + expect(await screen.findByLabelText(/可用总额 207/)).toBeTruthy(); expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy(); rerender( @@ -311,7 +315,14 @@ describe('PlatformEntryActiveFlowShell', () => { authUiMock.value.canAccessProtectedData = true; profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 37, + mudPointBalance: { + permanentPoints: 37, + monthlyPoints: 0, + monthlyResetsAt: null, + dailyFreePoints: 0, + dailyFreeResetPoints: 20, + dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', + }, }); profileCenterMock.isWalletLedgerOpen = true; @@ -355,10 +366,9 @@ describe('PlatformEntryActiveFlowShell', () => { profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ mudPointBalance: { - totalPoints: 66, permanentPoints: 66, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -370,7 +380,7 @@ describe('PlatformEntryActiveFlowShell', () => { setSelectionStage={vi.fn()} />, ); - await screen.findByLabelText('泥点 66'); + await screen.findByLabelText(/可用总额 66/); let resolveNextOwner!: (value: unknown) => void; profileClientMock.getPlatformProfileRechargeCenter.mockImplementation( @@ -387,7 +397,7 @@ describe('PlatformEntryActiveFlowShell', () => { />, ); - expect(screen.queryByLabelText('泥点 66')).toBeNull(); + expect(screen.queryByLabelText(/可用总额 66/)).toBeNull(); await waitFor(() => { expect(usePlatformWalletStore.getState()).toMatchObject({ ownerUserId: 'user-2', @@ -396,16 +406,15 @@ describe('PlatformEntryActiveFlowShell', () => { }); resolveNextOwner({ mudPointBalance: { - totalPoints: 67, permanentPoints: 67, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', }, }); - await screen.findByLabelText('泥点 67'); + await screen.findByLabelText(/可用总额 67/); }); it('coalesces visibility and focus refreshes when the page returns to the foreground', async () => { @@ -423,10 +432,9 @@ describe('PlatformEntryActiveFlowShell', () => { profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null); profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({ mudPointBalance: { - totalPoints: 10, permanentPoints: 10, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-04T00:00:00+08:00', @@ -438,7 +446,7 @@ describe('PlatformEntryActiveFlowShell', () => { setSelectionStage={vi.fn()} />, ); - await screen.findByLabelText('泥点 10'); + await screen.findByLabelText(/可用总额 10/); await act(async () => { document.dispatchEvent(new Event('visibilitychange')); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index baaf2fddd..5d8393d56 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -23,6 +23,7 @@ import { PlatformProfileRechargeModal } from '../../../packages/shared/src/compo import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal'; import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal'; import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime'; +import { resolveProfileMudPointDisplayTotal } from '../../../packages/shared/src/utils/mudPoints'; import { hasAppHistoryBackEntry, pushAppHistoryPath, @@ -404,7 +405,9 @@ export function PlatformEntryFlowShellImpl({ }); const legacyWalletBalance = walletOwnerMatchesCurrentUser ? (storedLegacyWalletBalance ?? - profileCenter.rechargeCenter?.walletBalance ?? + resolveProfileMudPointDisplayTotal( + profileCenter.rechargeCenter?.mudPointBalance, + ) ?? null) : null; @@ -1006,9 +1009,14 @@ export function PlatformEntryFlowShellImpl({ error={profileCenter.rechargeError} submittingProductId={profileCenter.submittingRechargeProductId} nativePayment={profileCenter.nativeWechatPayment} + membershipQuote={profileCenter.membershipQuote} + isLoadingMembershipQuote={profileCenter.isLoadingMembershipQuote} + membershipQuoteError={profileCenter.membershipQuoteError} onClose={() => profileCenter.setIsRechargeOpen(false)} onRetry={profileCenter.loadRechargeCenter} onBuy={profileCenter.buyRechargeProduct} + onRequestMembershipQuote={profileCenter.requestMembershipQuote} + onSubmitMembershipCheckout={profileCenter.submitMembershipCheckout} onConfirmNativePayment={profileCenter.confirmNativeWechatPayment} onCloseNativePayment={profileCenter.closeNativeWechatPayment} /> diff --git a/src/components/platform-entry/platformProfileFundsModel.ts b/src/components/platform-entry/platformProfileFundsModel.ts index 69926e1ba..051648037 100644 --- a/src/components/platform-entry/platformProfileFundsModel.ts +++ b/src/components/platform-entry/platformProfileFundsModel.ts @@ -11,6 +11,7 @@ const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = { snapshot_sync: '账户同步', membership_period_grant: '会员周期发放', membership_period_reset: '会员周期重置', + membership_upgrade_grant: '会员升级补点', daily_free_grant: '每日免费发放', daily_free_reset: '每日免费重置', asset_operation_consume: '资产操作消耗', diff --git a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx index 5b222cc95..7cb0803e5 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.recharge.test.tsx @@ -32,10 +32,9 @@ function deferred() { function mudPointBalance(totalPoints: number) { return { - totalPoints, permanentPoints: totalPoints, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-07T00:00:00+08:00', @@ -52,7 +51,6 @@ function nativeRechargeResponse(totalPoints = 80, orderId = 'order-a') { expirationCheckedAt: null, }, center: { - walletBalance: totalPoints, mudPointBalance: mudPointBalance(totalPoints), }, wechatNativePayment: { @@ -76,14 +74,13 @@ describe('usePlatformProfileCenterController recharge fallback', () => { ); }); - test('keeps the response legacy balance when no shared snapshot exists', async () => { + test('keeps the response three-pool balance when no shared snapshot exists', async () => { usePlatformWalletStore.getState().setWalletOwner('user-a'); profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 37, + mudPointBalance: mudPointBalance(37), membership: null, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }); @@ -92,7 +89,9 @@ describe('usePlatformProfileCenterController recharge fallback', () => { act(() => result.current.loadRechargeCenter()); await waitFor(() => { - expect(result.current.rechargeCenter?.walletBalance).toBe(37); + expect( + result.current.rechargeCenter?.mudPointBalance?.permanentPoints, + ).toBe(37); }); }); @@ -198,7 +197,9 @@ describe('usePlatformProfileCenterController recharge fallback', () => { expect(result.current.submittingRechargeProductId).toBeNull(); expect(result.current.nativeWechatPayment?.orderId).toBe('order-b'); expect(result.current.rechargePaymentResult).toBeNull(); - expect(result.current.rechargeCenter?.walletBalance).toBe(90); + expect( + result.current.rechargeCenter?.mudPointBalance?.permanentPoints, + ).toBe(90); expect(usePlatformWalletStore.getState()).toMatchObject({ ownerUserId: 'user-b', mudPointBalance: mudPointBalance(90), diff --git a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx index 4c5506bc4..40b0e4d75 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.test.tsx +++ b/src/components/platform-entry/usePlatformProfileCenterController.test.tsx @@ -19,19 +19,17 @@ vi.mock('../../services/platform-entry/platformProfileClient', async () => { }); const rechargeCenter: ProfileRechargeCenterResponse = { - walletBalance: 100, mudPointBalance: { - totalPoints: 100, permanentPoints: 100, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 0, dailyFreeResetsAt: null, }, membership: { status: 'normal', - tier: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -39,11 +37,12 @@ const rechargeCenter: ProfileRechargeCenterResponse = { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 30, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: false, }; diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index 208eaccd7..8600c2863 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -1,8 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { type PlatformProfileRechargeNativePaymentState } from '../../../packages/shared/src/components/PlatformProfileRechargeModal'; +import { resolveMembershipProductId } from '../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct'; import { type ConfirmWechatProfileRechargeOrderResponse, + type ProfileMembershipCycleKind, + type ProfileMembershipPlan, + type ProfileMembershipUpgradeQuote, type ProfileRechargeCenterResponse, type ProfileRechargeOrder, type ProfileRechargeProduct, @@ -29,6 +33,7 @@ import { requestWechatJsapiPayment } from '../../services/payment/wechatJsapiPay import { confirmWechatPlatformProfileRechargeOrder, createPlatformProfileRechargeOrder, + getPlatformProfileMembershipUpgradeQuote, getPlatformProfileRechargeCenter, getPlatformProfileReferralInviteCenter, getPlatformProfileWalletLedger, @@ -392,6 +397,13 @@ export function usePlatformProfileCenterController({ useState(null); const [submittingRechargeProductId, setSubmittingRechargeProductId] = useState(null); + const [membershipQuote, setMembershipQuote] = + useState(null); + const [isLoadingMembershipQuote, setIsLoadingMembershipQuote] = + useState(false); + const [membershipQuoteError, setMembershipQuoteError] = useState< + string | null + >(null); const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); const [walletLedger, setWalletLedger] = useState(null); @@ -431,6 +443,7 @@ export function usePlatformProfileCenterController({ const confirmingWechatRechargeOrderRef = useRef(null); const rechargeSubmissionRef = useRef(null); + const membershipQuoteRequestRef = useRef(null); const accountWriteAbortControllerRef = useRef(new AbortController()); const rechargeCenterReadRevisionRef = useRef(0); const accountLifecycleRevisionRef = useRef(0); @@ -555,6 +568,10 @@ export function usePlatformProfileCenterController({ setWechatRechargeOrderConfirmationState(null); setNativeWechatPayment(null); setSubmittingRechargeProductId(null); + setMembershipQuote(null); + setIsLoadingMembershipQuote(false); + setMembershipQuoteError(null); + membershipQuoteRequestRef.current = null; setIsWalletLedgerOpen(false); setWalletLedger(null); setWalletLedgerError(null); @@ -1028,7 +1045,7 @@ export function usePlatformProfileCenterController({ }, [isRechargeOrderCurrent, nativeWechatPayment]); const buyRechargeProduct = useCallback( - (product: ProfileRechargeProduct) => { + (product: Pick) => { const account = captureAccountLifecycle(); if ( rechargeSubmissionRef.current || @@ -1333,6 +1350,63 @@ export function usePlatformProfileCenterController({ ], ); + const requestMembershipQuote = useCallback( + (plan: ProfileMembershipPlan) => { + const account = captureAccountLifecycle(); + const requestSignal = accountWriteAbortControllerRef.current.signal; + if ( + !account.ownerUserId || + !isAccountLifecycleCurrent(account) || + requestSignal.aborted + ) { + return; + } + const requestToken = Symbol('profile-membership-quote'); + membershipQuoteRequestRef.current = requestToken; + const isLatestQuoteRequest = () => + isAccountLifecycleCurrent(account) && + membershipQuoteRequestRef.current === requestToken; + setMembershipQuote(null); + setMembershipQuoteError(null); + setIsLoadingMembershipQuote(true); + void getPlatformProfileMembershipUpgradeQuote(plan, { + signal: requestSignal, + }) + .then((quote) => { + if (!isLatestQuoteRequest()) { + return; + } + setMembershipQuote(quote); + }) + .catch((error: unknown) => { + if (!isLatestQuoteRequest()) { + return; + } + setMembershipQuoteError( + error instanceof Error ? error.message : '读取升级报价失败', + ); + }) + .finally(() => { + if (isLatestQuoteRequest()) { + setIsLoadingMembershipQuote(false); + } + }); + }, + [captureAccountLifecycle, isAccountLifecycleCurrent], + ); + + const submitMembershipCheckout = useCallback( + (plan: ProfileMembershipPlan, cycleKind: ProfileMembershipCycleKind) => { + // 会员商品没有商品配置行:标题与价格由后端按档位与周期现算, + // 这里只需要把 productId 交给同一条下单链路。 + buyRechargeProduct({ + productId: resolveMembershipProductId(plan, cycleKind), + kind: 'membership', + }); + }, + [buyRechargeProduct], + ); + const confirmNativeWechatPayment = useCallback(() => { if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { return; @@ -1864,8 +1938,6 @@ export function usePlatformProfileCenterController({ } return { ...rechargeCenter, - walletBalance: - mudPointBalance?.totalPoints ?? rechargeCenter.walletBalance, mudPointBalance: mudPointBalance ?? rechargeCenter.mudPointBalance, }; }, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]); @@ -1874,6 +1946,11 @@ export function usePlatformProfileCenterController({ closeNativeWechatPayment, closeProfilePopupPanel, confirmNativeWechatPayment, + isLoadingMembershipQuote, + membershipQuote, + membershipQuoteError, + requestMembershipQuote, + submitMembershipCheckout, copyInviteCode, inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState, inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState, diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index 561674520..26f47fac2 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -11,6 +11,8 @@ import type { ExternalApiKeyListResponse, ExternalApiKeyMutationResponse, ProfileDashboardSummary, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileRechargeOrder, ProfileReferralInviteCenterResponse, @@ -231,6 +233,22 @@ export function createPlatformProfileRechargeOrder( ); } +export function getPlatformProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/membership/upgrade-quote', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetPlan }), + }, + '读取升级报价失败', + options, + ); +} + export function confirmWechatPlatformProfileRechargeOrder( orderId: string, options: PlatformProfileRequestOptions = {}, diff --git a/src/stores/usePlatformWalletStore.test.tsx b/src/stores/usePlatformWalletStore.test.tsx index 2b133a265..d7a95d234 100644 --- a/src/stores/usePlatformWalletStore.test.tsx +++ b/src/stores/usePlatformWalletStore.test.tsx @@ -22,12 +22,10 @@ beforeEach(() => { vi.clearAllMocks(); usePlatformWalletStore.getState().resetWalletBalance(); profileClientMocks.getPlatformProfileRechargeCenter.mockResolvedValue({ - walletBalance: 42, mudPointBalance: { - totalPoints: 42, permanentPoints: 42, - limitedPoints: 0, - limitedExpiresAt: null, + monthlyPoints: 0, + monthlyResetsAt: null, dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-08-08T00:00:00+08:00', diff --git a/vitest.config.ts b/vitest.config.ts index c16d38606..d5187097f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -172,6 +172,9 @@ export default defineConfig({ // DirectProject 会话滚动的判据与 hook 与实现同目录:改滚动契约时用例就在旁边。 'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.ts', 'apps/ai-game-creator-shell/src/view/project-development/chat/**/*.test.tsx', + // 对话模型选择器的锁定项 / 降级文案与实现同目录。 + 'apps/ai-game-creator-shell/src/features/project-workspace/*.test.ts', + 'apps/ai-game-creator-shell/src/features/project-workspace/*.test.tsx', 'miniprogram/**/*.test.js', 'scripts/**/*.test.ts', 'packages/shared/src/contracts/*.test.ts',