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 8fecfe485..df1709dd4 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/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 51dc44f27..22e0d20e8 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -968,7 +968,9 @@ export function GameDistributionPublishPanel({
{cover ? null : ( - {'封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'} + { + '封面会作为游戏广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。' + } )} {coverGenerationDetail ? ( 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 66dfa522a..2c4796420 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 { ReactNode, Ref } from 'react'; import { useCallback, @@ -19,10 +19,15 @@ import { cachedLlmModelCatalog, type ClientLlmModel, type ClientLlmModelCatalog, + type ClientLlmUnavailableModel, LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +import { + unavailableModelFallbackNotice, + unavailableModelReasonLabel, +} from './modelAvailabilityCopy'; /** * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 @@ -91,6 +96,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 ?? '', @@ -189,6 +197,9 @@ export function ConversationModelSelect({ showBusy: boolean, epochAtRequest: number, ) => { + // 目录契约的 `unavailableModels` 是加法字段:旧缓存 / 局部数据可能缺失。 + // 统一归一化一次,避免下面 setState 与按 reason 取文案时解引用 undefined。 + const catalogUnavailableModels = catalog.unavailableModels ?? []; if ( mountedRef.current && (appliedRevisionRef.current !== catalog.revision || @@ -196,6 +207,7 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setUnavailableModels(catalogUnavailableModels); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); @@ -231,7 +243,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; @@ -306,6 +324,7 @@ export function ConversationModelSelect({ appliedRevisionRef.current = null; selectedRef.current = ''; setModels([]); + setUnavailableModels([]); setSelected(''); setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); @@ -368,6 +387,7 @@ export function ConversationModelSelect({ selectedRef.current = ''; setSelected(''); setModels([]); + setUnavailableModels([]); void syncCatalog(true); } window.addEventListener('focus', handleWindowFocus); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.test.ts b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.test.ts new file mode 100644 index 000000000..1dbbf7c08 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { + unavailableModelFallbackNotice, + unavailableModelReasonLabel, +} from './modelAvailabilityCopy'; + +describe('modelAvailabilityCopy', () => { + it('锁定项文案按 reason 取值,unknown 兜底', () => { + expect(unavailableModelReasonLabel('plan_required')).toBe('订阅计划不支持'); + expect(unavailableModelReasonLabel('disabled')).toBe('该模型已下线'); + expect(unavailableModelReasonLabel('unknown')).toBe('暂不可用'); + }); + + it('降级文案按 reason 取值,两桶都没有时泛化', () => { + expect(unavailableModelFallbackNotice('plan_required')).toBe( + '所选模型需要更高订阅计划,已切换为默认模型', + ); + expect(unavailableModelFallbackNotice('disabled')).toBe( + '所选模型已下线,已切换为默认模型', + ); + expect(unavailableModelFallbackNotice('unknown')).toBe( + '所选模型已不可用,已切回默认模型', + ); + expect(unavailableModelFallbackNotice(undefined)).toBe( + '所选模型已不可用,已切回默认模型', + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts new file mode 100644 index 000000000..b8135de01 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts @@ -0,0 +1,47 @@ +import type { ClientLlmModelUnavailableReason } from '../../services/llmModelCatalog'; + +/** + * 原因 → 文案的统一映射。 + * + * `unknown` 是契约的前向兜底,后端将来新增 `reason` 时旧客户端仍给出可读文案;两个 helper + * 共用本表,避免各自维护一份 switch 后漂移。 + */ +const REASON_COPY: Record< + ClientLlmModelUnavailableReason, + { label: string; fallback: string } +> = { + plan_required: { + label: '订阅计划不支持', + fallback: '所选模型需要更高订阅计划,已切换为默认模型', + }, + disabled: { + label: '该模型已下线', + fallback: '所选模型已下线,已切换为默认模型', + }, + unknown: { + label: '暂不可用', + fallback: '所选模型已不可用,已切回默认模型', + }, +}; + +/** 锁定项 hover / 键盘 focus 的原因文案。 */ +export function unavailableModelReasonLabel( + reason: ClientLlmModelUnavailableReason, +): string { + return (REASON_COPY[reason] ?? REASON_COPY.unknown).label; +} + +/** + * 已选模型不可用时的降级提示。 + * + * `unavailableReason` 命中目录不可用桶时按原因给具体文案;两桶都没有(后台直接删除) + * 传 `undefined`,用泛化文案。 + */ +export function unavailableModelFallbackNotice( + unavailableReason: ClientLlmModelUnavailableReason | undefined, +): string { + if (!unavailableReason) { + return REASON_COPY.unknown.fallback; + } + return (REASON_COPY[unavailableReason] ?? REASON_COPY.unknown).fallback; +} diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts index da82f4168..46fc37d52 100644 --- a/apps/ai-game-creator-shell/src/services/accountHost.ts +++ b/apps/ai-game-creator-shell/src/services/accountHost.ts @@ -1,6 +1,8 @@ import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, + ProfileMembershipPlan, + ProfileMembershipUpgradeQuote, ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, @@ -41,6 +43,15 @@ export function getClientProfileWalletLedger() { ); } +export function getClientProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, +) { + return invokeAccount( + 'read_profile_membership_upgrade_quote', + { targetPlan }, + ); +} + export type ClientProfileReferralInviteCenterResponse = { center: ProfileReferralInviteCenterResponse; inviteUrl: string; diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 03a440468..915e6feaf 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -1,39 +1,28 @@ +import type { + AgcAgentMode, + AgcModelProtocol, + LlmModelsResponse, + LlmModelSummary, + LlmModelUnavailableReason, + LlmUnavailableModel, +} from '@genarrative/shared'; + import { resolveTauriInvoke } from '../app/tauri'; -export type ClientLlmModel = { - displayName: string; - id: string; - /** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */ - agentMode: 'codex' | 'cc'; - /** 上游线协议;服务端未下发时按执行器的默认协议解释。 */ - protocol?: 'openai_responses' | 'openai_chat' | 'anthropic'; -}; - -export type ClientLlmModelCatalog = { - defaultModelId: string; - models: ClientLlmModel[]; - revision: number; -}; - -type ClientLlmModelWire = Omit & { - agentMode?: ClientLlmModel['agentMode'] | null; -}; - -type ClientLlmModelCatalogWire = Omit & { - models: ClientLlmModelWire[]; -}; +/** + * 模型目录类型直接复用 Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物: + * 不再手写第二份 `ClientLlmModel`,`agentMode` / `protocol` / `reason` 的取值以契约为准。 + */ +export type ClientLlmModel = LlmModelSummary; +export type ClientLlmUnavailableModel = LlmUnavailableModel; +export type ClientLlmModelUnavailableReason = LlmModelUnavailableReason; +export type ClientLlmAgentMode = AgcAgentMode; +export type ClientLlmModelProtocol = AgcModelProtocol; +export type ClientLlmModelCatalog = LlmModelsResponse; export async function loadClientLlmModels(): Promise { const invoke = resolveTauriInvoke(); if (!invoke) throw new Error('模型服务不可用'); - const catalog = await invoke( - 'load_game_creator_llm_models', - ); - return { - ...catalog, - models: catalog.models.map((model) => ({ - ...model, - agentMode: model.agentMode ?? 'codex', - })), - }; + // Rust typed command 已经把响应解析成 `LlmModelsResponse`(含 unavailableModels)。 + return invoke('load_game_creator_llm_models'); } diff --git a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts index c63b41eec..cc73afc8c 100644 --- a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts +++ b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts @@ -2,7 +2,14 @@ import { resolveTauriInvoke } from '../app/tauri'; import type { GameCreatorAppConfigView } from '../app/types'; import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi'; -export type { ClientLlmModel, ClientLlmModelCatalog } from './clientApi'; +export type { + ClientLlmAgentMode, + ClientLlmModel, + ClientLlmModelCatalog, + ClientLlmModelProtocol, + ClientLlmModelUnavailableReason, + ClientLlmUnavailableModel, +} from './clientApi'; let cached: ClientLlmModelCatalog | null = null; let inFlight: Promise | null = null; @@ -52,7 +59,9 @@ async function loadEffectiveCatalog(epoch: number) { id, displayName: id, agentMode: 'codex' as const, + protocol: 'openai_responses' as const, })), + unavailableModels: [], revision: --localRevision, }; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index cfeefa4ef..04cee0378 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11342,6 +11342,50 @@ button.design-workspace-tree__entry:hover, font-style: normal; line-height: 1.7; } +.conversation-model-menu-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; + min-height: 32px; + padding: 0 9px; + border-radius: 6px; + color: var(--platform-text-soft, #6b7280); + font: inherit; + font-size: 12px; + text-align: left; +} +.conversation-model-menu-option-locked { + position: relative; + cursor: not-allowed; +} +.conversation-model-menu-option-locked:hover { + background: var(--platform-button-ghost-fill, #f3f4f6); + outline: none; +} +/* 锁定项的原因提示:仅 hover 出现;锁定项不进入 Tab 序列,不再有键盘 focus 态。 */ +.conversation-model-menu-option-locked[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + right: 0; + bottom: calc(100% + 4px); + z-index: 30; + display: none; + width: max-content; + max-width: 220px; + padding: 3px 7px; + border-radius: 6px; + background: rgb(17 24 39 / 92%); + color: #fff; + font-size: 11px; + line-height: 1.5; + white-space: normal; + pointer-events: none; +} +.conversation-model-menu-option-locked[data-tooltip]:hover::after { + display: block; +} .conversation-model-menu-loading { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts index 7b9a93d73..8679d940b 100644 --- a/apps/ai-game-creator-shell/tests/accountHost.test.ts +++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts @@ -26,6 +26,7 @@ vi.mock('../src/services/platformSession', () => ({ import { confirmClientWechatProfileRechargeOrder, createClientProfileRechargeOrder, + getClientProfileMembershipUpgradeQuote, getClientProfileRechargeCenter, getClientProfileReferralInviteCenter, getClientProfileWalletLedger, @@ -50,6 +51,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a await getClientProfileRechargeCenter(); await getClientProfileWalletLedger(); await getClientProfileReferralInviteCenter(); + await getClientProfileMembershipUpgradeQuote('pro'); await createClientProfileRechargeOrder('product-1'); await confirmClientWechatProfileRechargeOrder('order-1'); await redeemClientProfileRewardCode('CODE-1'); @@ -58,6 +60,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a ['read_profile_recharge_center', undefined], ['read_profile_wallet_ledger', undefined], ['read_profile_referral_invite_center', undefined], + ['read_profile_membership_upgrade_quote', { targetPlan: 'pro' }], ['create_profile_recharge_order', { productId: 'product-1' }], ['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }], ['redeem_profile_reward_code', { code: 'CODE-1' }], diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 21bb4e55d..1be205dae 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -906,15 +906,13 @@ export function registerClientHomeTests() { it('starts from the client home and opens a project in the same window', async () => { // 账户与钱包的远端真相在 Rust typed command:渲染层只消费投影,不再自己发请求。 const rechargeCenter = { - walletBalance: 40, mudPointBalance: { - totalPoints: 40, - permanentPoints: 20, - limitedPoints: 0, - limitedExpiresAt: null, dailyFreePoints: 20, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00.000Z', + monthlyPoints: 0, + monthlyResetsAt: null, + permanentPoints: 20, }, products: [], membership: null, @@ -971,17 +969,17 @@ export function registerClientHomeTests() { ).toBeNull(); expect( within(screen.getByLabelText('账户资产')).getByRole('button', { - name: /^泥点 /, + name: /^泥点账户/, }), ).not.toBeNull(); expect( within(screen.getByLabelText('账户资产')).getByRole('button', { - name: '充值', + name: '会员与泥点', }), ).not.toBeNull(); fireEvent.mouseEnter( within(screen.getByLabelText('账户资产')).getByRole('button', { - name: /^泥点 /, + name: /^泥点账户/, }), ); expect(screen.queryByText('已读取账户')).toBeNull(); @@ -1026,19 +1024,17 @@ export function registerClientHomeTests() { it('opens the shared recharge modal from the wallet and sidebar entries', async () => { const rechargeCenter = { - walletBalance: 120, mudPointBalance: { - totalPoints: 120, - permanentPoints: 100, - limitedPoints: 20, - limitedExpiresAt: '2026-08-01T00:00:00Z', dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00Z', + monthlyPoints: 20, + monthlyResetsAt: '2026-08-01T00:00:00Z', + permanentPoints: 100, }, membership: { - status: 'inactive', - tier: 'normal', + status: 'normal', + plan: 'normal', startedAt: null, expiresAt: null, updatedAt: null, @@ -1046,7 +1042,9 @@ export function registerClientHomeTests() { cycleResetsAt: null, cycleGrantedPoints: 0, cycleRemainingPoints: 0, - cyclePeriodDays: 0, + cycleIndex: 1, + cycleCount: 1, + cycleKind: 'monthly', }, pointProducts: [ { @@ -1059,15 +1057,9 @@ export function registerClientHomeTests() { durationDays: 0, badgeLabel: '', description: '', - tier: 'normal', - membershipPeriodPoints: 0, - membershipPeriodDays: 0, - membershipQueueLimit: 0, - membershipDiscountBps: 0, }, ], - membershipProducts: [], - benefits: [], + membershipPlans: [], latestOrder: null, hasPointsRecharged: true, }; @@ -1078,10 +1070,9 @@ export function registerClientHomeTests() { rechargePaid ? { ...rechargeCenter, - walletBalance: 180, mudPointBalance: { ...rechargeCenter.mudPointBalance, - totalPoints: 180, + monthlyPoints: 20, permanentPoints: 160, }, } @@ -1142,10 +1133,9 @@ export function registerClientHomeTests() { }, center: { ...rechargeCenter, - walletBalance: 180, mudPointBalance: { ...rechargeCenter.mudPointBalance, - totalPoints: 180, + monthlyPoints: 20, permanentPoints: 160, }, }, @@ -1157,28 +1147,29 @@ export function registerClientHomeTests() { installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); - fireEvent.click(screen.getByRole('button', { name: '充值' })); + fireEvent.click(screen.getByRole('button', { name: '会员与泥点' })); - expect( - await screen.findByRole('dialog', { name: '购买更多泥点' }), - ).not.toBeNull(); - expect(screen.getByText('当前余额 120 泥点')).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); + await screen.findByRole('dialog', { name: '会员与泥点' }); + await screen.findByText('可用总额 120 泥点'); + fireEvent.click(screen.getByRole('button', { name: '关闭会员与泥点' })); fireEvent.click(screen.getByRole('button', { name: '我的' })); fireEvent.click(screen.getByRole('button', { name: '充值泥点' })); - expect( - await screen.findByRole('dialog', { name: '购买更多泥点' }), - ).not.toBeNull(); + await screen.findByRole('dialog', { name: '会员与泥点' }); expect(invoke).toHaveBeenCalledWith( 'read_profile_recharge_center', undefined, ); - fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ })); - expect( - await screen.findByRole('dialog', { name: '微信扫码支付' }), - ).not.toBeNull(); + fireEvent.click(screen.getByRole('tab', { name: '泥点充值' })); + fireEvent.click(screen.getByRole('button', { name: '充值 ¥6' })); + const checkoutDialog = await screen.findByRole('dialog', { + name: '确认订单', + }); + fireEvent.click( + within(checkoutDialog).getByRole('button', { name: '充值 ¥6' }), + ); + await screen.findByRole('dialog', { name: '微信扫码支付' }); expect(invoke).toHaveBeenCalledWith('create_profile_recharge_order', { productId: 'points_60', }); @@ -1186,20 +1177,24 @@ export function registerClientHomeTests() { await waitFor(() => { expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull(); }); - expect(screen.getByText('当前余额 180 泥点')).not.toBeNull(); + await screen.findByText('可用总额 180 泥点'); - fireEvent.click(screen.getByRole('button', { name: /60泥点.*购买/ })); + fireEvent.click(screen.getByRole('button', { name: '充值 ¥6' })); + const secondCheckoutDialog = await screen.findByRole('dialog', { + name: '确认订单', + }); + fireEvent.click( + within(secondCheckoutDialog).getByRole('button', { name: '充值 ¥6' }), + ); await waitFor(() => { expect(rechargeOrderRequestCount).toBe(2); }); - fireEvent.click(screen.getByRole('button', { name: '关闭购买更多泥点' })); + fireEvent.click(screen.getByRole('button', { name: '关闭会员与泥点' })); await act(async () => { releaseLateRechargeOrder?.(); }); - fireEvent.click(screen.getByRole('button', { name: '充值' })); - expect( - await screen.findByRole('dialog', { name: '购买更多泥点' }), - ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '会员与泥点' })); + await screen.findByRole('dialog', { name: '会员与泥点' }); expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 8d7b8ba04..045aa9194 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -13,23 +13,19 @@ beforeEach(() => { vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); }); -test('模型目录缺少 agentMode 时默认使用 codex,同时保留官方 cc 路由', async () => { - invoke.mockResolvedValue({ +test('模型目录直接透传 Rust typed command 的解析结果', async () => { + // agentMode / protocol 缺省已下沉到 Rust 的 load_game_creator_llm_models, + // 渲染层不再做二次归一化,这里断言原样透传即可。 + const catalog = { defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, ], revision: 1, - }); + }; + invoke.mockResolvedValue(catalog); - await expect(loadClientLlmModels()).resolves.toEqual({ - defaultModelId: 'quality', - models: [ - { id: 'quality', displayName: '高质量', agentMode: 'codex' }, - { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, - ], - revision: 1, - }); + await expect(loadClientLlmModels()).resolves.toEqual(catalog); expect(invoke).toHaveBeenCalledWith('load_game_creator_llm_models'); }); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index efdc1e037..33b00be56 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -202,7 +202,15 @@ test('shows manual refresh progress immediately without clearing the selected mo await act(async () => { resolveRefresh({ defaultModelId: 'quality', - models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }], + models: [ + { + id: 'quality', + displayName: '高质量', + agentMode: 'codex', + protocol: 'openai_responses', + }, + ], + unavailableModels: [], revision: 1, }); }); @@ -315,7 +323,7 @@ test('falls back to the default model when the saved selection was removed', asy })); const onReady = vi.fn(); render(); - await screen.findByText('所选模型已停用,已切换为默认模型'); + await screen.findByText('所选模型已不可用,已切回默认模型'); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { @@ -521,6 +529,16 @@ test('pre-send validation falls back when the selected model is disabled', async vi.mocked(loadClientLlmModels).mockResolvedValue({ defaultModelId: 'quality', models: [{ id: 'quality', displayName: '高质量', agentMode: 'codex' }], + // `fast` 仍在下线桶里:降级文案要按 `disabled` 取「该模型已下线」。 + unavailableModels: [ + { + id: 'fast', + displayName: '快速', + agentMode: 'cc', + protocol: 'anthropic', + reason: 'disabled', + }, + ], revision: 2, }); await act(async () => { @@ -528,7 +546,7 @@ test('pre-send validation falls back when the selected model is disabled', async }); // 状态文案现在走组件自带的底部 toast(portal 到 body):等它出现,别用同步查询。 expect( - await screen.findByText('所选模型已停用,已切换为默认模型'), + await screen.findByText('所选模型已下线,已切换为默认模型'), ).not.toBeNull(); expect(savedModelId).toBe('quality'); expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { @@ -740,3 +758,27 @@ test('keeps the applied catalog when the revision is unchanged', async () => { expect(screen.queryByRole('option', { name: '视觉' })).toBeNull(); expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); }); + +test('applies a changed account catalog after an explicit config-changed notification', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // 会员升降级只改变账号可用档位,目录 revision 不变;显式 notify 必须重置已应用 revision + // 并强制重新应用,否则锁定列表会保持陈旧(上一条用例锁住「仅靠 focus 不会更新」)。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量', agentMode: 'codex' }, + { id: 'vision', displayName: '视觉', agentMode: 'codex' }, + ], + revision: 1, + }); + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => + expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull(), + ); + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(); +}); diff --git a/apps/ai-game-creator-shell/tests/walletStore.test.ts b/apps/ai-game-creator-shell/tests/walletStore.test.ts index 984fb5cff..3cd3f64f4 100644 --- a/apps/ai-game-creator-shell/tests/walletStore.test.ts +++ b/apps/ai-game-creator-shell/tests/walletStore.test.ts @@ -19,10 +19,9 @@ import { useWalletStore } from '../src/stores/useWalletStore'; function detailedBalance(totalPoints: number) { return { - totalPoints, permanentPoints: Math.max(0, totalPoints - 20), - limitedPoints: 20, - limitedExpiresAt: '2026-08-01T00:00:00Z', + monthlyPoints: Math.min(20, totalPoints), + monthlyResetsAt: '2026-08-01T00:00:00Z', dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00Z', @@ -39,7 +38,6 @@ describe('useWalletStore', () => { test('keeps mudPointBalance as the authoritative detailed balance state', async () => { const mudPointBalance = detailedBalance(120); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 120, mudPointBalance, }); const { result } = renderHook(() => useWalletStore()); @@ -48,12 +46,10 @@ describe('useWalletStore', () => { await result.current.onWalletBalanceMayHaveChanged(); }); - expect(result.current.mudPointBalance).toEqual(mudPointBalance); - expect(result.current.legacyWalletBalance).toBe(120); + expect(result.current.mudPointBalance).toMatchObject(mudPointBalance); expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ - 'applyLegacyWalletBalanceSnapshot', 'applyWalletBalanceSnapshot', 'captureWalletBalanceSnapshot', 'legacyWalletBalance', @@ -81,7 +77,9 @@ describe('useWalletStore', () => { .getState() .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); @@ -106,20 +104,20 @@ describe('useWalletStore', () => { rejectRequest?.(new Error('过期请求失败')); await refresh; - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); test('rejects a response that omits mudPointBalance instead of inventing a breakdown', async () => { - clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 120, - }); + clientApi.getClientProfileRechargeCenter.mockResolvedValue({}); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toBeNull(); - expect(useWalletStore.getState().legacyWalletBalance).toBe(120); + expect(useWalletStore.getState().legacyWalletBalance).toBeNull(); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( '泥点明细读取失败', @@ -129,13 +127,14 @@ describe('useWalletStore', () => { test('uses mudPointBalance from recharge-center as the authoritative snapshot', async () => { const mudPointBalance = detailedBalance(180); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 999, mudPointBalance, }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); - expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); + expect(useWalletStore.getState().mudPointBalance).toMatchObject( + mudPointBalance, + ); }); test('discards an outdated response and performs one trailing refresh', async () => { @@ -148,7 +147,6 @@ describe('useWalletStore', () => { }), ) .mockResolvedValueOnce({ - walletBalance: 80, mudPointBalance: detailedBalance(80), }); @@ -159,7 +157,6 @@ describe('useWalletStore', () => { .getState() .onWalletBalanceMayHaveChanged(); resolveFirst?.({ - walletBalance: 120, mudPointBalance: detailedBalance(120), }); await Promise.all([firstRefresh, trailingRefresh]); @@ -170,7 +167,6 @@ describe('useWalletStore', () => { test('retains the last balance when a refresh fails', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ - walletBalance: 90, mudPointBalance: detailedBalance(90), }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); @@ -199,7 +195,6 @@ describe('useWalletStore', () => { const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); useWalletStore.getState().resetWalletBalance(); resolveRequest?.({ - walletBalance: 75, mudPointBalance: detailedBalance(75), }); await refresh; @@ -248,7 +243,6 @@ describe('useWalletStore', () => { test('binds the account owner and refreshes again when the window regains focus', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); @@ -267,16 +261,14 @@ describe('useWalletStore', () => { }); }); - test('keeps a successful legacy recharge total when the breakdown refresh fails', async () => { + test('keeps the last known good breakdown when the recharge center omits it', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ - walletBalance: 90, mudPointBalance: detailedBalance(90), }); const { result } = renderHook(() => useAccountWallet('user-a')); await act(async () => undefined); clientApi.getClientProfileRechargeCenter .mockResolvedValueOnce({ - walletBalance: 37, membership: null, pointProducts: [], membershipProducts: [], @@ -291,14 +283,13 @@ describe('useWalletStore', () => { await Promise.resolve(); }); - expect(result.current.legacyWalletBalance).toBe(37); - expect(result.current.mudPointBalance).toBeNull(); + expect(result.current.mudPointBalance?.totalPoints).toBe(90); + expect(result.current.mudPointBalanceStatus).toBe('error'); }); test('keeps ledger loading independent from the recharge lifecycle', async () => { let resolveLedger!: (value: { entries: [] }) => void; clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockImplementation( @@ -326,7 +317,6 @@ describe('useWalletStore', () => { test('hides account-owned dialogs and data when the account changes', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ - walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockResolvedValue({ entries: [] }); diff --git a/docs/README.md b/docs/README.md index 64673640a..ecaa3095f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -106,6 +106,17 @@ - [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md) - [编辑器模型定价配置](./【编辑器】模型定价配置管理方案-2026-06-22.md) +## 钱包与会员计费 + +- [双余额泥点与会员补差升级实施计划](./technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md):每日免费 / 月度 / 永久三池并列投影、按开通日自然月计期、年付 12 期与补差升级的规则细则、里程碑与验收判据;已落地(会员购买与升级的 C 端 UI 未做)。 +- [泥点三池与会员计费后端设计](./technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md):后端 DB schema、账本与幂等、领域数据结构与 HTTP 契约;会员档位改为枚举、目录表以枚举为主键,含破坏性迁移计划、遗留清理清单与落地状态;已落地。 +- [会员与泥点前端改造](./technical/【技术设计】会员与泥点前端改造-2026-10-03.md):共享充值弹层改为「会员与泥点」双 Tab(套餐卡 / 周期切换 / 补差升级 / 六档充值 / 套餐对比 / 确认页),金额只读后端目录与报价接口;Web 平台入口、图片编辑器与 AGC 桌面壳三处共用;含已确认决策与验收判据。 +- [泥点三池以单一总额为权威](./adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md):存储层保留 `wallet_balance` 单一权威总额,对外并列三池;永久泥点暂为推导余数,另记独立存储的 TODO。 +- [会员账期按开通日自然月](./adr/【ADR】会员账期按开通日自然月-2026-10-02.md):北京时间开通日锚点、自然月推进与月末夹取、年付 12 期的期数表达与取整口径。 +- [会员订单复用充值订单与补差升级幂等锚点](./adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md):会员订单复用 `profile_recharge_order`、`MembershipUpgradeGrant` 账本幂等、后端只读报价与退款人工复核边界。 +- [会员档位以枚举为权威](./adr/【ADR】会员档位以枚举为权威-2026-10-02.md):档位身份改为 Rust 枚举、目录表 `profile_membership_plan` 以枚举为主键、删除两处 `tier` 列与旧档位枚举、并发用哨兵值 `128` 表示不设上限。 +- [模型权限与并发上限的强制边界](./adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md):并发上限只在认领事务按账号过滤队列 job、只算 `running`;模型权限只约束 AGC LLM 模型、缺省 `Basic` 由后台人工标 `Full`;raw gpt-image-2 等直连同步生成暂不计入并发。 + ## 后端、运维与测试 - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) diff --git a/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md b/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md new file mode 100644 index 000000000..7833da484 --- /dev/null +++ b/docs/adr/【ADR】会员档位以枚举为权威-2026-10-02.md @@ -0,0 +1,57 @@ +# 【ADR】会员档位以枚举为权威 + +状态:已接受(2026-10-02;字段级设计与迁移见 +[`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](../technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)) + +## 背景 + +- 会员档位今天由 `RuntimeProfileMembershipTier` 枚举承载,但同一个「级别」有两套拼写:旧时长卡命名 + `Month/Season/Year` 与现档位命名 `Starter/Basic/Pro/Ultimate` 互为别名(`runtime_profile_membership_tier_rank` + 把它们映射到同一 rank);其中 `profile_recharge_product_config.tier` 这个枚举列还长在**在线**表上。 +- 会员目录今天不是表:它是 `profile_recharge_product_config` 的四行会员商品(`member_*`)加一组 + `module-runtime` 静态查表(rank / 每期泥点 / 排队上限 / 折扣 / 权益矩阵)。 +- 新产品档位为 `Starter ¥39/400/¥390 · Plus ¥99/1150/¥990 · Pro ¥299/3650/¥2990 · Max ¥699/8650/¥6990`, + 并新增**模型权限**(基础 / 基础+高性能)与**独立任务并发**(1 / 3 / 5 / 不设上限);旧命名与旧档位全部作废。 +- 会员侧未上线,无存量会员行;但 SpacetimeDB 的枚举按变体索引编码,删变体会让已落库的行错位。 + +## 决策 + +1. **档位身份是 Rust 枚举**:新增 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`, + 替代字符串 plan id;`Normal` 表示非会员,必须存在。对外 JSON 只传枚举 token(如 `"pro"`), + 服务端**严格解析**,不接受自由字符串。 +2. **目录表以该枚举为主键**:新建 `profile_membership_plan`,主键 `plan`,字段含 + `title` / `rank` / `month_price_cents` / `year_price_cents` / `period_points` / `model_access` / + `concurrent_job_limit` / `sort_order` / `enabled`。一行一档;年价是独立可配置字段, + 不做 `月价 × 10` 推导。后台按档位改价与权益。 +3. **删除旧档位枚举与两处 `tier` 列**:`profile_membership.tier` 与 `profile_recharge_product_config.tier` + 一并删除,`RuntimeProfileMembershipTier` 整体退役;`profile_membership` 新增 `plan`(枚举)与 + `cycle_index` / `cycle_count` / `cycle_kind`。 +4. **并发用哨兵值而不是 `Option`**:`concurrent_job_limit: u32`,`128` 表示「不设上限」; + `Normal = 1`、`Starter = 1`、`Plus = 3`、`Pro = 5`、`Max = 128`。 +5. **模型权限本期只落字段**:`RuntimeProfileMembershipModelAccess { Basic, Full }` 进目录与投影, + 但服务端提交路径本期不做拦截。 +6. **会员订单复用充值订单**,变更快照字段一律用枚举(`RuntimeProfileMembershipPlan` / + `RuntimeProfileMembershipCycleKind` / `RuntimeProfileMembershipChangeKind`),不用字符串。 + +## 影响与代价 + +- 这是对会员侧的一次**破坏性 schema 变更**(删列 + 删枚举),需要明确授权覆盖仓库「枚举只追加」规则: + 会员侧无存量行,`profile_recharge_product_config` 是种子配置,可重建 + 重播种。 +- 账本来源枚举仍只允许**末尾追加**(新增 `MembershipUpgradeGrant`),钱包侧形状不动。 +- 档位比较从「按 `tier` 的别名查表」改为「按目录 `rank`」,升级判断与金额解析都要改到枚举 + 目录。 +- 会员目录成为可配置数据后价格不再写死在代码;代价是必须保证 `Normal` 与上下架字段的种子正确, + 否则会出现不可购买或语义错误的档位。 + +## 备选方案与取舍 + +1. **保留 `plan: String` 当权威,`tier` 只标废弃**:改动最小;代价是档位身份仍有字符串与枚举两套真相, + 且后台改价与升级比较继续绑在旧枚举上。已作废。 +2. **目录写死在代码(枚举 + 静态映射)**:少一张表、价格可审计;代价是后台无法改价改权益。已作废。 +3. **只追加枚举变体(保留 `Month/Season/Year/Basic/Ultimate`)**:最保守;代价是把两套命名与废弃档位 + 永久留在权威模型里,与新目录语义直接冲突。已作废。 + +## 明确不做 + +- 不做 `tier → plan` 的存量回填(会员侧无存量)。 +- 本期不执行模型权限与并发上限的服务端拦截,只落字段与展示。 +- 不删除任何 `points_*` 充点商品行(历史订单与首充资格引用 `product_id`)。 diff --git a/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md b/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md new file mode 100644 index 000000000..7b09adc1c --- /dev/null +++ b/docs/adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md @@ -0,0 +1,57 @@ +# 【ADR】会员订单复用充值订单与补差升级幂等锚点 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +现有 `profile_recharge_order` 是「商品 + 金额 + 支付状态 + `points_delta`」的形状,微信支付下单与回调、 +未支付订单过期监听、退款结算与对账、后台订单列表五套链路全部围绕它。会员侧未上线,表里没有会员订单; +但会员订单要额外承载「变更前后的档位、周期、补点数、有效期」,而且升级补点必须防重复回调重复发点。 + +现有钱包幂等全靠**稳定账本 id**:扣费与退款是同一 id 的前缀互换(`asset_operation_consume:` ↔ +`asset_operation_refund:`),池子级退款回填依赖这条对称性。账本来源枚举里没有任何「升级补点」类型。 + +另外,权威契约已经把泥点支付视为「只能扣永久泥点的负余额不变量」,并明确会员充值没有可逆 grant 快照、 +退款统一 `manual_review`;新方案引入补差升级后,一条订单对应的权益变化不再只是「加一档」。 + +## 决策 + +1. **会员购买 / 升级订单复用 `profile_recharge_order`**,新增 kind 值(会员购买 / 会员升级), + 变更快照放到表末尾新增的带默认值字段里;`points_delta` 仍然只表示「钱包里加了几个泥点」。 + 不新建会员订单专表——复制五套支付与对账链路的代价远大于加宽一张表,而会员侧未上线没有存量要兼容。 +2. **补差升级新增账本来源类型 `MembershipUpgradeGrant`**,账本 id 用订单维度 + `membership-upgrade-grant:{user}:{order_id}`。重复回调落在同一个账本 id 上,天然幂等; + 同一期内连续升级(Starter→Plus→Pro)产生不同 `order_id`,因此不会互相吞掉。 +3. **升级报价由后端只读接口计算**(入参:目标档位 + 目标周期),返回应付金额、补点、变更后月度余额、有效期; + 下单时后端按同一组输入**重算并落订单快照**,前端只展示。取整规则(金额向上到分、补点向下取整) + 只允许有一处实现,前端展示价不是真相源。 +4. **支付成功才变更**:失败 / 关闭不动原状态;以 `profile_recharge_order` 的支付成功作为唯一事实, + 补点在支付成功回调里落账本并发点。 +5. **会员退款维持 `manual_review`**,但订单快照要能回答「变更前后档位、周期、补点数、有效期」, + 让人工复核有据可依。自动回滚不做。 +6. **连续升级以当前套餐为比较基准**,不回到最初套餐;降档、月转年、年转月一律等到期后再选。 + +## 影响与代价 + +- 订单表继续变宽;`kind` 取值会成为契约的一部分,需要在 `shared-contracts` 与后台筛选里同步。 +- 报价接口是纯读路径,但必须有契约测试保证「报价返回值 == 下单重算值」,否则会出现「看到的价和扣的钱不一致」。 +- 升级补点写的是月度池,退款回填的账本对称性不适用于升级(它没有对应的「扣费」半边), + 因此升级的补点只能靠 `manual_review` 回收,不能自动回缴——这是刻意的。 +- 账本来源枚举扩容后,后台按来源类型展示与筛选要一起更新;契约文档里的来源全集清单必须同批修订。 + +## 备选方案与取舍 + +1. **新建 `profile_membership_order` 专表**:语义最干净、字段不多不少;代价是支付 / 回调 / 退款 / + 过期监听 / 后台列表五套链路都要再实现一遍。已作废。 +2. **复用 `MembershipPeriodGrant` + metadata `action=upgrade`**:少一个枚举值;代价是来源类型语义变宽, + 后台按来源类型统计会混。已作废。 +3. **前端本地计算报价,后端下单时校验**:少一个接口;代价是两处取整实现必须逐字段对齐,且报价可被篡改。已作废。 +4. **升级走自动回收(像泥点退款一样扣永久池 + 欠账冻结)**:体验一致;代价是要定义「月度点已花掉」的 + 回滚顺序,且与「只有永久池可被回收」的现有契约冲突。已作废。 + +## 明确不做 + +- 不做会员权益的自动回滚与 grant 快照反向执行。 +- 不为会员订单新建独立支付通道;不改变现有微信支付回调语义。 +- 不做「重复购买」:当前同套餐同周期只显示「当前套餐」,不产生订单。 diff --git a/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md b/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md new file mode 100644 index 000000000..de13a093f --- /dev/null +++ b/docs/adr/【ADR】会员账期按开通日自然月-2026-10-02.md @@ -0,0 +1,58 @@ +# 【ADR】会员账期按开通日自然月 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +现有会员周期池是**固定 30 天**的:`profile_membership.cycle_period_days` 默认 +`PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS = 30`,`refresh_profile_membership_cycle` 按固定天数步进 +`cycle_started_at` / `cycle_resets_at`,并且内置会员商品的 `duration_days` 全是 30——**当前不存在年付, +也没有「一次发 12 期」这回事**。 + +新产品要求:按开通日计期(9/20 开通 → 10/20 到期,不按自然月)、年付一次购买后按 11 期 + 首发共 12 期逐月发放、 +每期刷新先清上期余额再发新额度、到期未续清月度余额但保留永久泥点。同时年付升级要按 +「后续完整月数 + 本期剩余时间比例」折算补付金额与补点。 + +会员侧未上线,没有存量会员数据需要迁移;但 `cycle_*` 字段、惰性刷新与退款回填已经围绕现有口径写好, +改造要保住这三条链路。 + +## 决策 + +1. **账期锚点 = 北京时间的开通日时刻**,按自然月推进,且**每一期都从原始开通日重算**而不是从上一期递推, + 月末夹取到当月最后一天(1/31 开通 → 2/28 → 3/31,不会漂移成 3/28)。 + 业务日与每日免费池已经在北京时间口径上,账期必须同口径。 +2. **年付表达为「同一 `expires_at` 内的第 n 期」**:新增 `cycle_index`(第几期)与 `cycle_count`(12 或 1), + `cycle_resets_at` 继续当刷新锚点,`profile_membership` 仍然只有一行活跃记录。 + `expires_at` = 开通时刻 + 12 个月(同样夹取)。 +3. **每期刷新先清上期余额再发新额度**;到期未续:清月度余额、停发,永久泥点保留可用;重新选择套餐 + (含月转年、年转月、降档)一律在到期后进行,本期不即时转换。 +4. **取整口径固定**:金额向上取到分、补点向下取整。 + 年付应付 = `(新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余毫秒 / 本期实际毫秒)`; + 年付本期补点 = `(新档每期额度 − 当前档每期额度) × 本期剩余比例`; + 后续各期按新档额度发放,原刷新日与到期日不变。 +5. **升级后月度余额的上限是自动成立的**:余额 ≤ 当期已发放额度、新档每期额度 ≥ 旧的,因此 + 「原剩余 + 补点 ≤ 新档每期额度」不需要额外夹取;`cycle_granted_points` 同步补上本次补点, + 否则退款回填的上限判据会算错。 + +## 影响与代价 + +- 惰性刷新(无 cron,靠每次带符号变动与快照构建触发)保持不变,改造点是推进函数与期数字段。 +- 月末夹取与闰年把「本期剩余比例」的分母绑定到当期实际毫秒,需要在领域层用测试固化 + (1/31、2/28、闰年 2 月、12/31 跨年)。 +- `cycle_period_days` 不再是权威口径,需要明确它是兼容残留还是被 `cycle_count` / 自然月规则取代。 +- 旧档位枚举(`Month` / `Season` / `Year` 与 `Starter` / `Basic` / `Pro` / `Ultimate`)与会员权益表里的 + 按档位列出的字段要一并退役,否则后台会出现无法解释的选项。 + +## 备选方案与取舍 + +1. **继续固定 30 天,年付做成 12 次 30 天**:改动最小;代价是与「按开通日计期」和年付周年到期直接矛盾。 + 已作废。 +2. **自然月 + 每期一行会员记录**:期数可直接查询;代价是主键冲突、活跃行唯一性与扣减选行都要重做。已作废。 +3. **UTC 锚点**:与每日免费池口径不一致,会出现「同一天两个到期时间」。已作废。 + +## 明确不做 + +- 不做自动续费;本期是单次购买,到期后由用户重新选择套餐。 +- 不支持本期即时月转年、年转月与降档。 +- 不引入 cron 或后台定时任务来推进期数,继续用现有的惰性刷新。 diff --git a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md new file mode 100644 index 000000000..658f92635 --- /dev/null +++ b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md @@ -0,0 +1,37 @@ +# 模型权限与并发上限的强制边界 + +## Status + +accepted(2026-10-03) + +## 背景与决策 + +`profile_membership_plan` 的 `model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端不做拦截。2026-10-03 决定升级为服务端强制,并划清本期边界: + +- **并发上限**只在已有的 `claim_external_generation_jobs_tx`(唯一的 `pending → running` 事务点)按账号过滤,只统计 `status = running` 的 `external_generation_job`;不新建队列、不设排队深度上限、不新增计数表(加 `(owner_user_id, status)` 组合索引现算)。上限读后台目录 `profile_membership_plan`,缺行失败关闭到 `Normal`(=1),`128` 哨兵表示不设上限。 +- **模型权限**只约束 AGC 的 LLM 模型:`AgcModel` 末位加权限档,**缺省 `Basic`(失败开放)**,由人工在后台「AGC 模型」页手动标 `Full`;在 `GET /api/llm/models` 与代理解析处校验,越权返回 4xx `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **直连同步生成暂不计入并发**:`/api/raw/v1/images/edit`(gpt-image-2)、`editor_project` 直连图片 / 图标 / 背景、角色资源与音频等路径不产生 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径留作后续设计。 +- 不设灰度开关,直接对所有账号生效。 + +## 考虑过的替代方案 + +- 新增 SpacetimeDB「在飞租约表」统一统计队列 job 与直连同步生成:更完整,但需要租约、心跳与回收语义,本期未做。 +- 把直连同步生成统一改走 `external_generation_job`:最一致,但要重做 raw / editor 直连接口,风险最大。 +- 模型档位缺省 `Full`(失败关闭):更安全,但上线即需先标注全部基础模型,运营成本高。 + +## 后果 + +- 模型限制在人工标注 `Full` 之前不会拦截任何请求。 +- `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。 +- 无灰度开关;出问题只能回滚发版。 + +## 修订:列表改为可用 / 不可用分桶(2026-10-03) + +原实现让 `GET /api/llm/models` 只返回本档可用模型(`Basic` 看不到 `Full`),降级用户在客户端只会看到「所选模型已停用」的笼统提示,且产品无法在列表里露出可升级的高档模型。修订为: + +- 返回两个平行数组:`models` 只放本档可用(`enabled` 且档位允许);`unavailableModels` 放目录里其余全部,元素额外带 `reason` 枚举。 +- `reason` 取值 `plan_required`(档位不够)/ `disabled`(后台停用)/ `unknown`(前向兜底);同时停用且档位不够时取 `disabled`(升级也解锁不了,不能标 `plan_required`)。 +- 上游真实模型名(`modelId`)两桶都不下发;`defaultModelId` 必须落在 `models` 内。 +- 分桶只是展示层的信息补充,**不放松服务端强制**:`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 仍按档 4xx。 +- `unavailableModels` 是加法字段:旧客户端忽略后行为不变;`reason` 在 wire 侧带 `unknown` 兜底,后端新增取值不会让旧客户端解析失败。 +- 目录 DTO 与枚举改为 ts-rs 单一真源(whole DTO 导出),`agentMode` / `protocol` 由 `String` 收敛为 Rust 枚举。 diff --git a/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md b/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md new file mode 100644 index 000000000..08dc0b339 --- /dev/null +++ b/docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md @@ -0,0 +1,60 @@ +# 【ADR】泥点三池以单一总额为权威 + +状态:已接受(2026-10-02;规则细则、里程碑与验收见 +[`【实施计划】双余额泥点与会员补差升级-2026-10-02`](../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md)) + +## 背景 + +钱包今天只有一个权威数字:`profile_dashboard_state.wallet_balance`。它**包含**每日免费泥点和会员月度泥点, +不是「永久余额」,也不是「充值余额」。三个池子不是三份余额,而是对这一个数字的三种解读: + +- 每日免费池真实存储(`profile_daily_free_points.remaining_points`,北京时间 0 点清零重发); +- 会员周期池真实存储(`profile_membership.cycle_remaining_points`,到 `cycle_resets_at` 换期清零); +- 永久泥点在读取时由 `wallet_balance − 每日免费剩余 − 月度剩余` 推导 + (`build_profile_mud_point_balance_response`,`api-server/src/runtime_profile.rs`)。 + +也就是说,现在的「永久」不是一种来源,而是「没人认领的余数」:充值、邀请奖励、兑换码、每日任务、注册赠送, +以及任何回不到原池的退款都落在这里。由此产生两个后果:对外投影里 `totalPoints` 带着池语义 +(= 含每日与月度的总额),而 `permanentPoints` 是减法的产物,产品要的「两张独立余额卡片」在契约上并不成立。 + +边界:每日免费池、永久余数口径、账本与历史数据、充点商品与历史订单都在线上;只有会员侧(周期池、档位枚举、 +会员商品与权益)未上线,可以重做。 + +## 决策 + +1. **存储层保持单一权威总额。** 不新增 `permanent_balance` 列:每日免费存 `profile_daily_free_points`, + 月度存 `profile_membership.cycle_remaining_points`,永久泥点 = 总额 − 每日免费 − 月度。 + 只有一处写入锚点(`apply_profile_wallet_signed_delta`),三池不会互相打架。 +2. **对外投影把三池做成并列一等字段**:`dailyFreePoints` / `monthlyPoints` / `permanentPoints`, + 各自带自己的元数据(每日重置时间;月度到期日与刷新日、期数;永久无到期)。 + `totalPoints` 不再承担池语义,永久泥点也不允许表现为「总额的剩余」。 +3. **扣减顺序固定为 每日免费 → 月度 → 永久**;可用总额 = 月度 + 永久,每日免费单独展示与单独计量。 + 每日免费当天 24 点清零,先扣它对用户最有利,退款回填的账本感知逻辑也不用改。 +4. **非会员来源一律落入永久泥点**:充值、邀请、兑换码、每日任务、注册赠送,以及跨天 / 跨周期回填失败的退款。 +5. **TODO(本 ADR 的已知临时性)**:后续把永久泥点升级为独立存储列。触发条件是出现「必须区分充值本金与 + 奖励点数」(例如按充值金额精确回缴)或出现第二个需要独立写永久池的副作用。届时迁移口径为 + 「存量用户回填 `permanent = 现有余数` + 双写切换 + 保留回滚方案」,不改变本 ADR 的对外三池投影。 + +## 影响与代价 + +- 池层面的 bug 会表现为**永久泥点虚高**,而不是余额对不上;排查必须回到账本的 `permanentPointsDelta`。 +- 存储层不强制「永久只能由充值 / 奖励增加」,语义靠账本来源类型白名单保证。 +- 对外字段改名(月度从 `limitedPoints` 变为 `monthlyPoints`)会同步影响 `shared-contracts`、 + 前端共享组件、AGC 客户端与后台页面,必须同批改完。 +- 每日免费与永久余数在线,任何口径变化都是**在线契约变更**;`totalPoints` 的旧含义需要一次性退役干净, + 不留「有时含每日、有时不含」的歧义。 + +## 备选方案与取舍 + +1. **永久独立存储列(在线迁移)**:永久成为一等字段、可加白名单校验;代价是为存量用户回填、 + 双写切换、准备回滚,并让每日免费与月度的每次变动都要保证三处一致。已作废,记为 TODO。 +2. **三池都是真相源,`total` 改为派生投影**:领域模型最干净;代价是 `wallet_balance` 的所有读路径 + (账本 `balance_after`、后台快照、冻结 / 预留、对账工具)与全部历史账本数据都要改,在线风险最大。已作废。 +3. **保持现状(永久继续是余数且不改对外字段)**:零改动;代价是产品要的「两张独立余额卡片」在契约上 + 永远表达不出来。已作废。 + +## 明确不做 + +- 本轮不改 `wallet_balance` 的口径与在线数据结构,不做永久泥点独立存储。 +- 不给每日免费泥点引入过期以外的第二种语义(不并入可用总额、不参与「月度不足时补扣」)。 +- 不为奖励点数发明独立过期规则。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md index 3c14f4a5e..327ac50b1 100644 --- a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -17,6 +17,8 @@ 2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。 3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。 +> 2026-10-03 修订:本条「`GET /api/llm/models` 形状不变」只约束本里程碑自身;后续「模型权限」变更已把该接口改为可用 / 不可用分桶(新增 `unavailableModels` 与 `reason` 枚举),见 `docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md` 的修订节。 + ## 不在本里程碑内 - 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 21352fc95..05e2aa88a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-10-03 每日免费发放额为 0 时前端隐藏该池 + +- 背景:运营需要一个可逆的「不提供每日免费泥点」状态。不给它新增 `retired` 状态位或新字段,直接把后台配置 `daily_free_points_per_day` 配成 0,让「每日免费发放额」这个普通数值自己表达;前端据此隐藏每日免费相关入口。 +- 决策(信号取 `dailyFreeResetPoints` 而非剩余):`ProfileMudPointBalance.dailyFreeResetPoints <= 0` 表示不提供该池。`dailyFreePoints`(当天剩余)为 0 是每天用完后的正常状态,不能当信号。 +- 决策(显示规则 `remaining > 0 || reset > 0`):当天仍有存量(配置中途改成 0,但当天已发未用完)时必须继续展示——扣点顺序不变,这部分存量会被优先扣减,隐藏后用户会「有余额看不见、还被先扣」。判定收口到 `packages/shared/src/utils/mudPoints.ts` 的 `shouldShowProfileDailyFreePool`,三端共享组件共用,消费方 controller 不改。 +- 决策(后端放开校验、不新增契约字段):`server-rs/crates/module-runtime/src/commands.rs` 的 `daily_free_points_per_day` 由「必须 > 0」放宽为 `0..=i64::MAX`,错误变体改名为 `DailyFreePointsPerDayOverflow`;`0` 本就是 `u64` 合法值,`shared-contracts` / ts-rs 生成物 / OpenAPI 不改。发放与账本逻辑不变(`amount_delta == 0` 时后端本就跳过写账)。 +- 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。 +- 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。 + + ## 2026-10-05 创作者主页与关注粉丝的产品边界 - 产品已确认:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。 @@ -194,6 +204,25 @@ - 影响范围:`apps/ai-game-creator-shell/src/styles.css`、`src/view/home/index.tsx`、`src/view/template-library/index.tsx`。 - 验证:Chromium 真机量测(1440x800 / 1440x560 / 390x844 / 390x560,带与不带横幅)——首页 / 模板库 / 项目页改动前后高度一致,帮助页从「被裁 112~150px 且无可滚动祖先」变为正好等于 `.window-chrome__content` 高度并可滚动到底;`npm run typecheck`、`npm run check:encoding`、`git diff --check`、`eslint` 通过。 +## 2026-10-02 泥点双余额与会员补差升级方案(拷问定稿) + +- 决策(对外投影):余额接口并列暴露每日免费泥点 / 月度泥点 / 永久泥点三池,永久泥点不再表现为「总额的剩余」;`totalPoints` 的池语义退役,`limitedPoints` 更名 `monthlyPoints`。存储层不动——`wallet_balance` 仍是唯一权威总额,永久泥点 = 总额 − 每日免费 − 月度,并留 TODO 待后续改为独立存储(触发条件见 ADR)。 +- 决策(扣减与展示):扣减顺序维持「每日免费 → 月度 → 永久」,可用总额 = 月度 + 永久,每日免费保留并单独计量与展示。 +- 决策(会员侧重做):会员侧未上线,旧档位 `Month/Season/Year` 与 `Basic/Ultimate` 及后台按档位权益字段直接删除;账期改为按开通日自然月(当月最后一天夹取),年付 12 期用 `cycle_index` / `cycle_count` 表达,仍只有一行活跃会员记录。 +- 决策(充点侧):6 档充点、取消首充赠送;复用旧 4 档 `points_*` id 并把赠送置 0,新增两档(`points_1280` / `points_3280`),不删除任何行(历史订单与首充资格按 `user_id + product_id` 引用)。 +- 决策(升级):后端只读报价接口 + 下单重算并落订单快照;会员订单复用 `profile_recharge_order`(新增 kind 与末位变更快照字段);新增账本来源 `MembershipUpgradeGrant`,幂等锚 `membership-upgrade-grant:{user}:{order_id}`;金额向上取到分、补点向下取整,年付按「后续完整月数 + 本期实际月长占比」;会员退款维持 `manual_review`,只靠订单快照支撑人工复核。 +- 边界:每日免费池、永久余数口径、账本与充点订单**在线上**;只有会员侧未上线,因此会员侧零迁移、钱包侧不允许口径漂移。 +- 决策(会员档位枚举化):会员档位身份改为 Rust 枚举 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`,绝不字符串;新增 `profile_membership_plan` 目录表并**以该枚举为主键**(后台按档位改价 / 改权益);`profile_membership.tier` 与 `profile_recharge_product_config.tier` 两列删除、旧 `RuntimeProfileMembershipTier` 整体退役。会员侧无存量行,属经用户授权的破坏性变更;账本来源枚举仍只允许末尾追加 `MembershipUpgradeGrant`。 +- 决策(目录权益):目录一行一档,含月价、**独立可配置年价** `year_price_cents`、每期额度、模型权限 `Basic` / `Full`(本期只落字段、不执行拦截)、并发上限 `u32`(**哨兵 128 = 不设上限**,不用 `Option`);`Normal` = 0 点 / `Basic` / 并发 1 / rank 0。 +- 决策(目录数值):`Starter` ¥39 / 400 / ¥390 · `Plus` ¥99 / 1150 / ¥990 · `Pro` ¥299 / 3650 / ¥2990 · `Max` ¥699 / 8650 / ¥6990。 +- 权威入口:[实施计划](../../technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md);设计见 [`【技术设计】泥点三池与会员计费后端设计`](../../technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md);决策见 [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md)、[`【ADR】会员账期按开通日自然月`](../../adr/【ADR】会员账期按开通日自然月-2026-10-02.md)、[`【ADR】会员订单复用充值订单与补差升级幂等锚点`](../../adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md)、[`【ADR】会员档位以枚举为权威`](../../adr/【ADR】会员档位以枚举为权威-2026-10-02.md)。 +- 状态:**已落地**。后端(领域模块、`profile_membership_plan` 表与播种、两处 `tier` 列删除、订单变更快照、自然月账期、6 档充点、三池余额投影、升级报价接口、后台档位接口)、契约生成(ts-rs → `packages/shared/src/contracts/generated/`)、共享组件与平台入口三池展示、AGC 壳、后台「会员档位」页与充值商品页收敛均已实现并通过门禁。明确不做:模型权限与并发上限只落字段、不提供 `availableTotalPoints`、会员购买 / 升级的 C 端 UI。 +- 实现期补充决断(未改变上述范围): + - 余额 DTO 由 Rust 单一真源经 ts-rs 导出,前端 re-export 而非手写第二份字段列表;`u64` 标 `ts(type = "number")` 避免 `bigint` 分叉,`check:generated-bindings` 以「重新生成逐字节比对」守漂移。 + - 「合计」由前端派生(`packages/shared/src/utils/mudPoints.ts` 的 `resolveProfileMudPointBalanceView`),月度刷新时刻固定按北京时间格式化,不把第二份口径放回后端。 + - 会员订单 `product_id` 编码「档位 + 周期」(`membership-{plan}-{cycleKind}`),会员商品不进 `profile_recharge_product_config`;支付确认不做出售校验,避免付款后档位被下架导致权益丢失。 + - 后台 `rank` 不可改(升级比较基准由代码内目录决定),`normal` 档位的价格与额度由后端强制归零。 + ## 2026-10-01 游戏广场评分展示边界 - 用户确认广场卡片增加一位小数的 10 分制平均分与评分人数,无有效评价显示“暂无评分”;保留现有排序、筛选、卡片打开详情及返回上下文。 @@ -9624,6 +9653,136 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界(未验证):macOS 真机的 sidecar 加载与 `check-macos-bundle.mjs` 包内容门禁未在本机验证;Linux 门禁按新配置不再要求 sidecar 资源,需 CI 实跑确认转绿。 - 关联:issue #519、master `fb130d184`、`docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md`、CI run 3083。 +## 2026-10-03 后台补全会员计费运维面:订单快照、用户会员状态、三池命名与表标签 + +- 背景:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.6 声称「订单列表 / 退款人工复核读取 §2.4 快照」,但管理端 `AdminRechargeOrderEntryPayload` 没有任何会员字段;用户详情不展示会员档位与账期,钱包月度池仍叫「会员限时」,通用表浏览器缺 `profile_membership_plan` 标签。 +- 决策(订单快照透出):新增 `AdminMembershipOrderChangePayload`(变更类型 / 前后档位 / 前后账期 / 前后每期泥点 / 补点增量 / 期数 / 前后到期与重置 / 价格拆分 JSON)挂到 `AdminRechargeOrderEntryPayload.membershipChange`,由 `map_membership_order_change` 从 module-runtime 既有的 `RuntimeProfileMembershipOrderChangeSnapshot` 直接映射;非会员订单为 `null`。只读,不改下单 / 退款语义。 +- 决策(用户会员状态):`AdminUserDetailResponse` 新增 `membership: AdminProfileMembershipPayload`,api-server 复用 `get_profile_recharge_center` 的 `membership` 投影,不再新开一条 admin 专用读路径。 +- 决策(三池命名只在契约 / UI 层):`AdminProfileWalletPayload.membership_limited_points` 改名为 `monthly_points`(TS `monthlyPoints`),后台钱包区块「会员限时」改为「月度泥点」。**SpacetimeDB 列名 `membership_limited_points` 与生成绑定保持不动**——持久化改名属破坏性变更,须先确认迁移计划。 +- 决策(展示复用):新建 `apps/admin-web/src/config/membershipDisplay.ts` 收敛档位 / 账期展示名与 `format*`,`AdminMembershipPlanPage`、`AdminRechargeOrderPage`、`AdminUserDetailDialog` 共用,不再各留一份 `planLabels`。 +- 影响范围:`server-rs/crates/shared-contracts/src/admin.rs`、`server-rs/crates/api-server/src/admin_recharge.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,config/membershipDisplay.ts,pages/AdminMembershipPlanPage.tsx,pages/AdminRechargeOrderPage.tsx,components/AdminUserDetailDialog.tsx}`、`scripts/admin-web-fake-api.mjs`、设计文档 §5.6/§10.2、本文件。 +- 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 +- 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 + + +## 2026-10-03 模型权限与并发上限的强制边界(拷问定案) + +- 背景:`model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端零拦截;用户点出 raw gpt-image-2 这类同步生成,确认「只统计 job 的 `running`」覆盖不到它,因此先把「并发单位」定义清楚再强制。 +- 决策(并发上限只约束队列 job):强制点放在唯一的 `pending → running` 事务点 `claim_external_generation_jobs_tx`,认领时按账号统计 `running` 数、达上限则跳过(任务留在 `pending`,天然排队,不新建队列)。只算 `running`(含过期待回收的 `expired_running`);失败退回 `pending` 重试释放名额;自身过期 `running` 回收豁免;上限读 `profile_membership_plan.concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 哨兵为不设上限;计数用新增 `(owner_user_id, status)` 组合索引现算,不建计数表;`/api/external/v1` 任务同样计入;不设排队深度上限。 +- 决策(模型权限只约束 AGC LLM 模型):`AgcModel` 末位加权限档、缺省 `Basic`(失败开放),由人工在后台「AGC 模型」页手动标 `Full`;`GET /api/llm/models` 按档过滤并把 `defaultModelId` 取可用集合首个,`/api/llm/responses` 经 `resolve_requested` 后校验、越权返 `MODEL_NOT_AVAILABLE_FOR_PLAN`;本地残留旧选择明确 4xx,不静默回退;不设灰度开关。 +- 边界(本轮不做):直连同步生成(raw gpt-image-2、`editor_project` 直连图片 / 图标 / 背景、角色资源、音频)没有 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续 TODO。 +- 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + +## 2026-10-03 模型权限与并发上限落地实现 + +- 背景:承接同日「模型权限与并发上限的强制边界(拷问定案)」,把定案按 §11.4 编码约定落地;本文记实现拆分与验证,不再重复决策理由。 +- 实现(`module-runtime`,`b78003143`):新增 `agc_model_access.rs`(`AgcModelAccess::Basic/Full`、`MODEL_NOT_AVAILABLE_FOR_PLAN`、`AgcModelResolveError::{Unavailable, NotAvailableForPlan}`、`RuntimeProfileMembershipModelAccess → AgcModelAccess`);`AgcModel` 末位追加 `#[serde(default)] access`(存量 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 增加 `available_models_for / default_model_id_for / resolve_requested_for`,默认模型优先取登记项、不在档内则回退目录顺序首个可用项、无可用项返回 `Unavailable`。 +- 实现(`spacetime-module`,`db8d467b8`):`external_generation_job` 追加 btree 组合索引 `(owner_user_id, status)`;`claim_external_generation_jobs_tx` 在 `pending → running` 前按 owner 现算 `running` 行数并过滤,达上限留在 `pending`;只算 `running`(含过期待回收),自身过期 `running` 回收豁免,`pending` 不占名额;`effective_profile_concurrent_job_limit` 走 `profile_membership.plan → concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 为不限;抽取纯函数 `external_generation_claim_within_concurrency_limit` 并补单测。 +- 实现(后台,`919b4b01d`):`AdminAgcModel` 新增 `access`(缺省 `basic`),api-server 保存时只接受 `basic/full`,`GET` 回读 / `PUT` 写入;后台「AGC 模型」页新增「权限档」列与选择器,fake API 与页面用例覆盖。 +- 实现(`api-server`,`9b1a538d2`):新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 解析账号档位到 `AgcModelAccess`,缺会员行 / 缺目录行 / 读失败统一失败关闭到 `Basic`;`GET /api/llm/models` 按档过滤且默认项取该档可用集合;`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全改按档解析;越权映射 403 + `MODEL_NOT_AVAILABLE_FOR_PLAN`,目录外 / 停用仍为 422。 +- 术语口径:`Basic` / `Full` 是模型对会员档的要求,`Normal` 等是会员档位;缺省语义统一「失败开放到基础档、失败关闭到 1 并发」——模型档缺省 `Basic` 是失败开放(不拦),并发上限缺行失败关闭到 `Normal`(=1)。 +- 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。 +- 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。 + +## 2026-10-03 模型列表改为可用 / 不可用分桶(修订) + +- 背景:`GET /api/llm/models` 原按档过滤,降级用户在客户端只看到「所选模型已停用」的笼统提示,且无法在列表里露出可升级的高档模型。 +- 决策(契约):返回 `models`(本档可用)与 `unavailableModels`(目录里其余全部,带 `reason` 枚举)两个平行数组,**不新增** `available` 布尔;`reason` 取 `plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`;`unavailableModels` 保持目录顺序、恒定下发(无内容为空数组);上游真实模型名两桶都不下发;`defaultModelId` 必须落在 `models` 内;某档一个可用模型都没有时保留 503 `MODEL_UNAVAILABLE`。 +- 决策(兼容):`unavailableModels` 是加法字段,旧客户端忽略后行为不变,无需能力协商;`reason` 在 wire 侧带 `unknown` 兜底,后端将来新增取值不会让旧客户端解析失败;服务端按档强制不变。 +- 决策(客户端):AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,仅 hover / 键盘 focus 出 tooltip(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」;不再新增「当前已选不可用」的 API 字段,前端自行从 `unavailableModels` 推断。 +- 决策(ts-rs 单一真源):整个目录 DTO 与枚举导出到 `packages/shared/src/contracts/generated/`;新增 barrel `packages/shared/src/llm/modelCatalog.ts` 并由 `packages/shared/src/index.ts` 转发;`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts`(`module-runtime` re-export);`revision: u64` 标 `#[ts(as = "f64")]`;AGC 前端删除手写 `ClientLlmModel`,改从 `@genarrative/shared` 根导入。 +- 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + +## 2026-10-03 模型权限改走只读 procedure(性能收口) + +- 背景:`api-server` 的 AGC 模型权限解析原复用 `get_profile_recharge_center`,它带幂等的账期 / 免费点刷新与钱包 / 商品等无关快照字段,位于每个 `/api/llm/*` 请求热路径;同日「落地实现」条目已把它列为 TODO。 +- 决策:新增只读 procedure `get_profile_agc_model_access_and_return`(输入沿用 `RuntimeProfileMembershipGetInput`,结果 `RuntimeProfileAgcModelAccessSnapshot { user_id, plan, model_access }`,仅 editor generation runtime service identity 可调):只读会员账期投影 + `profile_membership_plan.model_access`,缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`,不刷新、不写库;`api-server` 改调它,失败关闭语义不变。 +- 影响范围:`module-runtime`(领域类型 `9e397fe9c`)、`spacetime-module`(procedure,同提交)、生成绑定、`spacetime-client`(`get_profile_agc_model_access`)、`api-server/src/llm/model_access.rs`(`b0dc94b46`)、技术设计 §11.4。 +- 验证:`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 51 passed;`npm run check:encoding`、`git diff --check`;`npm run check:spacetime-schema` 失败项仍为分支既有 `profile_membership` / `profile_recharge_product_config` 表字段变更,与本次仅新增 procedure、未改表无关。 + +## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) + +- 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 +- 决策(错误码随 procedure 结果透传):`module-runtime` 新增 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparseable` / `MembershipProductRetired` / `InvalidPlanConfig`)与内部 `RuntimeProfileMembershipDomainError { code, message }`;三个会员 procedure 结果(升级报价、充值中心 / 下单、后台档位 upsert)末尾追加 `error_code: Option<...>`,拒绝点显式赋码,不再做字符串分类。 +- 决策(客户端错误类型):`spacetime-client` 新增 `SpacetimeClientError::ProcedureRejected { code, message }`;`api-server` 据此返回 400(provider `runtime-profile`),基础设施错误保持 502。字段校验类错误不产码,仍落 502。 +- 决策(行为变化):`会员目录缺少档位 ...` 与「已迁移的会员商品」原先落 502,现在明确 400;兑换码链路本轮未改造,仍保留一个文案匹配函数。procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs}`、生成绑定、`server-rs/crates/api-server/src/{runtime_profile.rs,asset_billing.rs,editor_project.rs}`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check --workspace`;`api-server runtime_profile::tests` 41 passed;`module-runtime --lib membership::` 39 passed;`npm run check:encoding`、`npm run check:spacetime-schema`、`cargo fmt -- --check`、`git diff --check` 通过。 + +## 2026-10-03 会员目录播种策略定为「保留懒播种 + 明示副作用」 + +- 背景:评审第 20 项指出「只读」procedure(报价 / 充值中心 / 后台查询)首次读取时会经 `ensure_default_profile_membership_plan` 写库,与代码注释「不写库」矛盾。三条候选:①保留懒播种并承认副作用;②只靠 init + 发布期 migration 播种;③只读投影不落表、内存回退代码内目录。 +- 决策:选 ①。`init_profile_membership_plan_catalog` 只在数据库首次创建时执行,增量发布不重跑;「表已存在但为空」的存量库必须靠读取 helper 懒播种兜底,否则目录为空会让下单 / 报价失败。保留懒播种,并把「只读入口在目录为空的首次调用会写库、表非空后幂等只读」明确写进代码注释与设计文档 §7,消除注释与实现的矛盾。 +- 未选 ②/③ 的原因:②依赖发布流程保证存量库被种上,当前没有这条保证;③改动读路径回退逻辑,风险与收益不匹配。将来若做发布期 migration,再删除懒播种。 +- 影响范围:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`(`ensure_default_profile_membership_plan` / `init_profile_membership_plan_catalog` / `membership_plan_row` / `membership_plan_records` / 报价与建单函数注释)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §7/§10.2、本文件。 + +## 2026-10-03 兑换码链路补机器可读错误码(typed error 收口) + +- 背景:评审第 12 项指出 `SpacetimeClientError::ProcedureRejected` 的 `Display` 丢掉 `code`,而 `api-server/src/runtime_profile.rs` 的 `is_runtime_profile_redeem_code_domain_error` 仍在精确匹配 7 条中文 `error_message` 来判断兑换码拒绝该返回 400 还是 502,文案一改就静默退化,与会员链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / `InvalidReward`);`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRewardCodeRedeemProcedureError::{Classified, Plain}` 在拒绝点显式赋码,入参与钱包类错误保持 `error_code = null`。 +- 决策(客户端 / BFF):`SpacetimeClientError::ProcedureRejected` 的 `code` 改为 `RuntimeProfileProcedureRejectionCode`(聚合 `Membership` 与 `RewardCodeRedeem` 两类),`Display` 输出 `[code] message` 保留机器原因;`api-server` 的 `map_runtime_profile_client_error` 只读变体的 `code` / `message`,`AppError.code` 取 `code.as_str()`,删除 `is_runtime_profile_domain_error` 与 `is_runtime_profile_redeem_code_domain_error` 两个文案匹配函数。 +- 决策(行为变化):兑换码的 `兑换码不存在` / `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效` 由原来的 502 变为 400 且带机器码;procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs}`、生成绑定、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check -p api-server`;`cargo test -p api-server runtime_profile::tests` 41 passed;`cargo test -p module-runtime`、`cargo test -p spacetime-client`、`cargo test -p spacetime-module` 全通过;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 会员计费评审第 13–20 项收口:契约枚举化、生成类型、计费与结算防御 + +- 背景:评审剩下一批 medium / low 的契约与防御性缺陷,用户逐项确认后一次性修复;每项单独提交,便于回溯。 +- 决策(契约枚举化,13/14/18):`shared-contracts` 新增 `ProfileMembershipStatusToken`(`normal` / `active`),`AdminProfileMembershipPayload` 的 `plan` / `status` / `cycle_kind` 改用 token 枚举;5 个会员 token 枚举补 `ts_rs::TS` + `ts(export, ...)`,前端 `packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` 改为生成类型别名 / re-export,禁止手写联合类型;`AdminAgcModel.access` 由裸 `String` 改为 `ProfileMembershipModelAccessToken`,未知值反序列化即拒。 +- 决策(计费防御,15/16):`quote_runtime_profile_membership_upgrade` 显式拒绝价格 / 每期泥点 `<=`,堵住 0 元升级;`advance_beijing_months_clamped` 去掉 `expect`,超出 i64 微秒可表示范围(约 ±29 万年)时按方向饱和到 `i64::MAX` / `i64::MIN`,不改签名以免牵动 `membership_cycle_window` / `membership_expires_at`。 +- 决策(错误链 typed,17):`resolve_llm_router_client` 返回 `Result<_, AppError>`,`load_owner_llm_catalog` 的 `503 + MODEL_ACCESS_UNAVAILABLE` 与 `resolve_requested_for` 的 `MODEL_UNAVAILABLE` 不再被拍平成中文,Chat Completions 与 `/api/llm/models`、代理端点错误码对齐。 +- 决策(去掉退役入参,19):后台 upsert 请求 DTO 与 SpacetimeDB procedure 入参移除 `bonus_points` / `duration_days`,`profile_recharge_product_config` 退役列保留、写死 0;删除 `RuntimeProfileFieldError::InvalidRechargeProductFields`;后台表单去掉「首充赠送」,假数据同步。 +- 决策(结算归一,20):`frozen_membership_cycle_matches` 比较双方都取 `max(1)`,修复 `cycle_index == 0` 迁移行被永久判成「会员状态已变化」的问题;不新增写库副作用。 +- 影响范围:`shared-contracts/{runtime,admin}.rs`、`module-runtime/{domain,commands,errors}.rs` 与 `module-runtime/src/{membership/upgrade,membership/cycle,agc_model_access}.rs`、`spacetime-client/{active,active/mapper/runtime_profile,module_bindings}`、`spacetime-module/src/runtime/active/profile.rs`、`api-server/src/{runtime_profile,llm/mod,agc_models}.rs`、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminRechargeProductPage.tsx`、`scripts/admin-web-fake-api.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件。 +- 验证:`cargo test -p api-server` 1206 passed;`-p module-runtime` / `-p spacetime-client` / `-p spacetime-module` 全通过;`npm run spacetime:generate`、`npm run check:spacetime-schema`(92 表)、`npm run check:generated-bindings`(117 文件)、`npm run admin-web:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 后台兑换码管理补机器可读错误码(typed error 收口) + +- 背景:`bd06e4269` 删除中文文案匹配后,`admin_disable_profile_redeem_code` / `admin_upsert_profile_redeem_code` / `admin_list_profile_redeem_codes` 仍只回传裸 `error_message`。这些 procedure 的 `ok = false` 是明确的业务拒绝(停用不存在的码、入参非法、私有码缺可兑换用户),却被 `spacetime-client` 拍平成 `Procedure(String)`,在 `api-server` 落 502,与会员 / 核销两条链路已落地的 typed error 不一致。 +- 决策(module 侧赋码):`module-runtime` 新增 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / `InvalidField` / `AllowedUsersRequired`);`RuntimeProfileRedeemCodeAdminProcedureResult` 与 `RuntimeProfileRedeemCodeAdminListProcedureResult` 末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileRedeemCodeAdminProcedureError::{Classified, Plain}` 在拒绝点显式赋码,`RuntimeProfileFieldError` 统一归 `invalid_field`,私有码缺可兑换用户归 `allowed_users_required`,停用不存在的码归 `not_found`。 +- 决策(客户端 / BFF):`RuntimeProfileProcedureRejectionCode` 增加 `RedeemCodeAdmin` 聚合变体,`spacetime-client` 两个后台 mapper 有 `error_code` 时走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有 `map_runtime_profile_client_error`,无需再引入字符串分类。 +- 决策(行为变化):后台兑换码的 `兑换码不存在`、入参校验失败、私有码缺可兑换用户由 502 变为 400 且带机器码;procedure result 新增字段属 wire 契约变更,要求 module 与 api-server 同版本部署。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 30 passed;`cargo test -p api-server runtime_profile::tests` 42 passed;`cargo test -p module-runtime --lib` 113 passed;`npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 后台邀请码管理补机器可读错误码 + +- 背景:后台邀请码的 `admin_upsert_profile_invite_code` / `admin_list_profile_invite_codes` 与上一轮的后台兑换码同病——`map_runtime_profile_invite_code_admin_procedure_result` 把 `ok = false` 一律拍成 `Procedure(String)`,`邀请码已被其他用户占用` 这类明确业务拒绝在 `api-server` 落 502,且 `map_runtime_profile_client_error` 的注释夸大成 «profile procedure 的业务拒绝都已 typed»。 +- 决策:`module-runtime` 新增 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / `InUse`);两个后台邀请码 procedure 结果末尾追加 `error_code: Option<...>`;`spacetime-module` 用 `ProfileInviteCodeAdminProcedureError::{Classified, Plain}` 赋码,字段校验归 `invalid_field`、被他人占用归 `in_use`;`RuntimeProfileProcedureRejectionCode` 增加 `InviteCodeAdmin` 聚合变体,`spacetime-client` mapper 有码走 `ProcedureRejected`、缺失时保持 `Procedure`;`api-server` 复用既有映射,并把注释收窄为 «带 typed error_code 的四条链路»。 +- 行为变化:后台邀请码的入参校验失败与 `邀请码已被其他用户占用` 由 502 变为 400 且带机器码;procedure result 新增字段仍要求 module 与 api-server 同版本部署。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。 +- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-module` 271 passed。 + +## 2026-10-03 档位无可用模型改用独立错误码 MODEL_UNAVAILABLE_FOR_TIER + +- 背景:`AgcModelResolveError::Unavailable`(422)与 `NoModelForTier`(503)此前共用 `MODEL_UNAVAILABLE`(见本文件 2026-10-03 的列表契约条目),只按 `error.code` 分派的客户端无法分辨「单个模型不可用」与「整个档位无可用模型(目录 / 档位配置问题)」,两者修法不同。 +- 决策:`module-runtime` 新增常量 `MODEL_UNAVAILABLE` 与 `MODEL_UNAVAILABLE_FOR_TIER`,`NoModelForTier.code()` 返回后者,`Unavailable` 仍返回 `MODEL_UNAVAILABLE`;HTTP 状态(422 / 503)与端点行为不变,仅把错误码拆细,`resolve_error_codes_are_stable` 同步断言三码两两不同。 +- 影响范围:`server-rs/crates/module-runtime/src/agc_model_access.rs`、`server-rs/crates/api-server/src/llm/{mod,model_access}.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §11.4、本文件。 +- 验证:`cargo test -p module-runtime --lib agc_model_access`;`cargo test -p api-server llm::tests`。 + +## 2026-10-03 会员响应 token 反序列化前向兼容 + +- 背景:`shared-contracts` 的 5 个会员 token 枚举同时被入参 / 响应 DTO 复用,此前响应侧也严格反序列化;后端一旦新增 plan / status / cycle 取值,AGC Tauri shell 的 `read_profile_recharge_center` / `read_profile_membership_upgrade_quote`(`account_api.rs` 用 `serde_json::from_value` 解析整份响应)就会得到 `响应格式无效`,整个钱包页不可用。 +- 决策:只给**响应** DTO 的 token 字段挂 `#[serde(deserialize_with = "...")]` 做前向兼容,未知取值 fail-open 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`;真实权益 / 下单金额以后端重算为准,避免把付费会员显示成非会员后引导重复购买)。**入参** DTO 不改,非法 token 仍在反序列化即拒;不改枚举本身的 `Deserialize`,避免 `Unknown` 变体污染入参与穷尽 match。 +- 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`(4 个 helper + `ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / `ProfileMembershipUpgradeQuoteResponse` 的 8 个字段)、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §2.5。后台 DTO(`AdminProfileMembershipPayload` / `AdminMembershipOrderChangePayload`)只有 TS 消费方,不做 Rust 侧处理。 +- 验证:`cargo test -p shared-contracts --lib` 106 passed(含 `membership_response_tokens_tolerate_unknown_values_from_newer_backend`、`membership_request_tokens_stay_strict_for_unknown_values`)。 + +## 2026-10-03 会员错误码 `ProductIdUnparsable` 拼写修正(未上线,直接破坏性改名) + +- 背景:`RuntimeProfileMembershipErrorCode::ProductIdUnparsable` 拼写有误,生成绑定与对外 `error.code = "product_id_unparsable"` 同错。 +- 决策:该 PR 尚未上线,不留兼容,直接改名为 `ProductIdUnparseable`,wire 串同步改为 `"product_id_unparseable"`;重新生成 SpacetimeDB 绑定。 +- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active/mapper/runtime_profile.rs,module_bindings/runtime_profile_membership_error_code_type.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、设计文档 §5.8、本文件(含上文旧条目中的同名标识符同步更正)。 +- 验证:`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p spacetime-module` 271 passed。 + +## 2026-10-03 会员结构体也走 ts-rs 生成类型(去前端手写漂移) + +- 背景:会员 token 枚举早已 `ts(export)`,但承载它们的结构体(`ProfileMembershipPlanResponse`、`ProfileMembershipResponse`、`ProfileRechargeCenterResponse`、`ProfileRechargeProductResponse`、`ProfileRechargeOrderResponse`、`ProfileMembershipUpgradeQuoteResponse`、`ProfileMembershipPlanAdminListResponse`、`AdminUpsertProfileMembershipPlanRequest`、`ProfileDailyFreePointsResponse`、`ProfileMembershipUpgradeQuoteRequest`)仍是前端手写,字段与后端可能漂移。 +- 决策:这 10 个结构体补齐 `ts_rs::TS` + `ts(export)`;64 位整数字段统一补 `ts(type = "number")`(ts-rs 默认 `bigint`,JSON 实际是 number)。`packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` 改为 re-export 生成类型,仅前端需要收窄的 `kind` / `status` 联合用 `Omit` 组合保留;`ProfileRechargeCenterResponse` 的 `pointProducts` / `latestOrder` 同样收窄。 +- 影响范围:`server-rs/crates/shared-contracts/src/runtime.rs`、`packages/shared/src/contracts/generated/*`(新增 10 个文件)、`packages/shared/src/contracts/runtime.ts`、`apps/admin-web/src/api/adminApiTypes.ts`、设计文档 §2.5。 +- 验证:`npm run check:generated-bindings`(shared-contracts 23 个文件);根 `npm run typecheck`、`apps/admin-web` 与 AGC shell 主 `tsc` 通过;`cargo test -p shared-contracts --lib` 106 passed;`PlatformProfileRechargeModal` / `usePlatformProfileCenterController` vitest 14 passed。 + ## 2026-10-04 AGC 窗口标题栏项目标签改认「当前项目」,运行中项目降级为圆点与徽标 - 背景:issue #618。窗口标题栏那枚标签原来被「正在运行的项目」整块接管——`WindowChrome` 只要存在活动回合或运行快照读取失败,就把当前工作区标题换成 `ActiveProjectRunsPanel`(titlebar 形态),主文案取开始时间最晚的在跑项目。于是「当前项目没在跑、后台别的项目在跑」时,标签显示的是别的项目(现场:当前项目 `gameagent-ff7f3240`,标签 `gameagent-c3af9c7e`),用户以为自己开错了项目;读取失败时标签还会变成「正在运行的项目读取失败」,同样顶掉当前项目名。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8fb88c73c..a584c0285 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -57,6 +57,7 @@ - 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。 - 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 - 修改 SpacetimeDB schema 时遵守字段追加/default 约束,同步 migration、表目录、生成绑定,并运行 schema 检查;删除、改名、重排或改类型前先确认迁移计划。 +- `shared-contracts` 响应 DTO 只增不删:新增字段一律可选(`Option`,或带安全 `#[serde(default ...)]`),身份 / 状态字段保持必填;删除、改名、改类型或改必填属性前先确认版本化计划,不保留仅服务旧客户端的 legacy shim。详见后端架构文档「shared-contracts DTO 变更规则」。 - 日志不递归输出完整配置、应用状态或 provider client;新增字段默认不进入安全摘要。所有 provider 配置类型(`LlmConfig`、`OssConfig`、`WechatConfig`、`WechatPayConfig`、`MattingConfig`、`VolcengineSpeechConfig`、`Hyper3dSettings`、`VectorEngineImageSettings`、`VectorEngineAudioSettings`)都必须手写脱敏 `Debug`:只输出枚举、数值、布尔、有界标识与 `` 占位,凭据、私钥、路径与 URL 一律不递归格式化,并各带一条哨兵值回归用例(`cargo test -p platform-* debug`)。新增同类配置必须照此办理,不允许直接 `derive(Debug)`。 - HTTP 横切能力集中在 Axum/Tower 中间件:正常与降级路由复用追踪层;指标与 trace 使用 `MatchedPath` 模板及固定兜底,不把请求 ID、实际资源 ID 或 query 放入指标标签。在途请求通过 RAII guard 覆盖 Future 取消与 panic unwind;请求执行和响应体存活分别计量,不能把 handler 耗时当作 SSE 全生命周期。 - 业务依赖在组合根显式装配,Axum `FromRef` 只抽取可浅拷贝的窄能力。项目元数据与 External API 鉴权不持有完整 `AppState`,测试经相同接口注入替代依赖。集中鉴权仍保留方法级 fallback、公开入口、MCP 和 body limit 顺序;Provider span 跳过完整参数,不隐藏计费、重试、幂等或事务规则。 diff --git a/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md b/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md new file mode 100644 index 000000000..1aeaeefad --- /dev/null +++ b/docs/technical/【实施计划】双余额泥点与会员补差升级-2026-10-02.md @@ -0,0 +1,207 @@ +# 【实施计划】双余额泥点与会员补差升级 + +> 适用范围:`server-rs/crates`(SpacetimeDB 模块、`module-runtime`、`api-server`、`shared-contracts`) +> 与前端余额展示 / 充值弹层(`packages/shared`、`apps/ai-game-creator-shell`、`apps/admin-web`)。 +> 状态:方案定稿(2026-10-02),未实施。本文是开发依据;术语见 `CONTEXT.md`,决策见四份 ADR。 +> 字段级后端设计(DB schema / 账本 / 领域结构 / HTTP 契约 / 开放决策)见 +> [`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](./【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)。 + +## 0. 一句话交付与验收判据 + +一句话:把「一个总额 + 三视图」的钱包改造成对外**并列三池**(每日免费 / 月度 / 永久)的记账, +并在此之上建成月付 / 年付会员——按开通日自然月计期、年付 12 期逐月发放、支持补差升级与到期重选。 + +验收判据(全部满足才算完成): + +1. 余额接口并列返回每日免费泥点、月度泥点(含到期日、刷新日、期数)、永久泥点, + 不再出现「永久泥点 = 总额 − 其他池」的对外口径。 +2. 扣减顺序恒为 每日免费 → 月度 → 永久;可用总额 = 月度 + 永久;每日免费单独计量与展示。 +3. 月付 / 年付按**开通日自然月**计期;年付共 12 期,每期刷新先清上期余额再发新额度; + 到期未续清月度余额、停发,永久泥点保留可用。 +4. 补差升级按 §3.3 公式计算;金额向上取到分、补点向下取整;刷新日与到期日不变; + 防重复回调重复发点。 +5. 在线部分零回归:每日免费池、永久泥点口径、账本历史、充点订单与首充资格不受影响; + `npm run check:spacetime-schema`、`npm run check:encoding`、`git diff --check` 通过。 + +## 1. 现状事实基线 + +| 关注点 | 现状 | +|---|---| +| 权威余额 | `profile_dashboard_state.wallet_balance`,**包含**每日免费与月度 | +| 每日免费池 | `profile_daily_free_points`(北京时间 `day_key`、`granted_points` / `remaining_points`),在线 | +| 月度池 | `profile_membership.cycle_*`(`cycle_started_at` / `cycle_resets_at` / `cycle_granted_points` / `cycle_remaining_points` / `cycle_period_days`),**固定 30 天、无年付、无期数**,会员侧未上线 | +| 永久池 | **推导余数** `wallet_balance − 每日免费剩余 − 月度剩余`,无独立表,在线 | +| 扣减顺序 | 每日免费 → 会员周期 → 永久(`apply_profile_wallet_signed_delta`) | +| 退款回填 | 账本感知,跨天 / 跨周期回填失败的金额落永久池 | +| 账本 | `profile_wallet_ledger` 只追加,幂等靠稳定 id;16 种来源类型,**无升级补点** | +| 充值商品 | `profile_recharge_product_config`:四档充点 `points_60/180/300/680`(基础 ¥1 = 10,后三档首充赠送 50%),会员商品 `duration_days` 全 30 | +| 对外投影 | `ProfileMudPointBalanceResponse`:`totalPoints = wallet_balance`、`limitedPoints = cycle_remaining_points`、`permanentPoints = 总额 − 每日 − 月度` | +| 后台权益 | `ProfileMembershipBenefitResponse` 含 `month/season/year/starter/basic/pro/ultimate` 七个旧档位字段 | +| 年付 / 补差 | 全仓无实现 | + +冲突点(本次必须一次性收口): + +- `totalPoints` 带池语义(含每日与月度),与「可用总额 = 月度 + 永久」直接冲突。 +- `limitedPoints` 是全仓唯一出现「限时」措辞的地方,与「月度泥点」规范词冲突。 +- 原细则表只写「月度优先扣减」,与权威契约的「每日免费优先」冲突——本方案维持每日免费优先。 +- 权威契约把充值商品写死为四档带首充赠送,与本方案的 6 档无赠送冲突。 + +## 2. 术语 + +规范词见 `CONTEXT.md` 的「泥点与会员计费」:每日免费泥点、月度泥点、**永久泥点**、永久余数、会员账期、 +补差升级、可用总额。实现侧只保留一个规范词:**永久泥点**(`_Avoid_: 不限时泥点`、充值余额、无限泥点)。 + +## 3. 规则细则(合并去重,开发以此节为准) + +### 3.1 双余额记账 + +| 规则 | 月度泥点 | 永久泥点 | +|---|---|---| +| 来源 | 会员每期额度 | 直接充值 + 非会员奖励(历史赠点保留) | +| 有效期 | 当期有效,到期清零 | 永不过期 | +| 扣减顺序 | 每日免费耗尽后优先 | 月度不足时补扣 | +| 刷新 | 下期发新额度,旧余额不结转 | 不刷新,保留余额 | +| 升级时 | 剩余保留 + 按 §3.3 补发差额 | 完全不变 | +| 展示 | 独立卡片 + 到期 / 刷新日期 | 独立卡片 + 永久标记 | + +**每日免费泥点保留**,并位于扣减顺序最前(本方案对原细则的补充): +每日免费当天 24 点清零,先扣它对用户最有利,且不用改动现有的退款回填语义。 +可用总额 = 月度 + 永久,每日免费单独一行展示,不并入可用总额。 + +示例:月度剩 100 + 永久 500,消费 150 → 先扣 100 月度再扣 50 永久。 + +「永久」指不过期,创作照常扣点。 + +### 3.2 账期与刷新 + +- 按**开通日**计期(9/20 开通 → 10/20 到期),不按自然月;锚点为北京时间的开通日时刻。 +- 每一期从**原始开通日**重算并夹取月末(1/31 开通 → 2/28 → 3/31),不从上一期递推。 +- 年付:开通发首期,后续按月再发 11 期,共 12 期;`expires_at` = 开通时刻 + 12 个月(同样夹取)。 +- 每期刷新:先清上期余额,再发新额度。 +- 到期未续:清月度余额、停发;永久泥点保留可用。 +- 本期为单次购买,不自动续费;到期后由用户重新选择套餐(月转年、年转月、降档一律到期后再选)。 + +### 3.3 补差升级 + +``` +月付应付 = 新月价 − 当前月价 +月付补点 = 新套餐每期额度 − 当前套餐每期额度 +年付应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余时间比例) +年付本期补点 = (新每期额度 − 当前每期额度) × 本期剩余时间比例 +后续各期额度 = 新档每期额度 +``` + +- 本期剩余时间比例 = 本期剩余毫秒 / 本期实际毫秒(`cycle_started_at` → `cycle_resets_at`); + 后续完整月数 = 从本期 `cycle_resets_at` 到 `expires_at` 之间剩余的完整自然月期数。 +- 费用向上取到分,补点向下取整。 +- 升级后月度余额 = 原剩余月度余额 + 补点(已用点不返还,永久点不动); + `cycle_granted_points` 同步 +补点,否则退款回填的上限判据会算错。 +- 有效期 = 原刷新日与原到期日不变。 +- 连续升级以**当前套餐**为比较基准(Starter→Plus 再 →Pro 时按 Plus vs Pro 算,不回到最初套餐)。 +- 支付成功才变更;失败 / 关闭不动原状态;防重复回调重复发点。 + +参考示例(口径来自产品细则,实施时用领域测试固化): + +| 变更 | 补付 | 补点 | 变更后月度余额 | 有效期 | +|---|---|---|---|---| +| Plus 月 → Pro 月 | ¥200 | 2,500 | 3,115(原剩 615) | 原到期日不变 | +| Plus 年 → Pro 年 | ¥1,083.34 | 1,250 | 1,865(原剩 615) | 原到期日不变 | + +### 3.4 允许操作矩阵 + +| 当前状态 | 可操作 | 不可操作 | +|---|---|---| +| 无会员 / 已到期 | 开任意月付或年付 | — | +| 月付会员 | 升更高月付 | 即时月转年、降档(到期后再选) | +| 年付会员 | 升更高年付 | 年转月、降档(到期后再改) | +| 同套餐同周期 | — | 重复购买(显示「当前套餐」) | + +### 3.5 与现状的差异清单 + +1. `limitedPoints` → `monthlyPoints`(全仓改名,含 `shared-contracts`、`packages/shared` 契约、AGC、后台)。 +2. `totalPoints` 的池语义退役:对外只给三个池;不提供「可用总额」后端字段,前端按需相加。 +3. 会员周期从固定 30 天改为按开通日自然月;新增 `cycle_index` / `cycle_count`。 +4. 新增年付(12 期)与补差升级;新增后端只读报价接口。 +5. 账本来源枚举新增 `MembershipUpgradeGrant`。 +6. 会员订单复用 `profile_recharge_order` + 末位变更快照字段。 +7. 充点商品复用旧 4 档 id 并把赠送置 0(取消首充赠送),新增 ¥128 / ¥328 两档,共 6 档;不删除任何行(历史订单与首充资格引用 `product_id`)。 +8. 会员档位改为枚举 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`; + 会员目录迁到 `profile_membership_plan` 并以**该枚举为主键**(后台按档位改价); + 删除 `RuntimeProfileMembershipTier`、`profile_membership.tier` 与 `profile_recharge_product_config.tier`。 + +## 4. 数据模型与账本 + +- **不动**:`profile_dashboard_state.wallet_balance`(在线唯一权威总额)、`profile_daily_free_points`、 + `profile_wallet_ledger` 既有形状与 id 方案、永久余数推导口径。 +- `profile_membership`:**删除** `tier` / `cycle_period_days`;新增 `plan`(`RuntimeProfileMembershipPlan` 枚举,字段名 `plan`、类型必须是枚举)/ `cycle_index` / `cycle_count` / `cycle_kind`。 +- 新增 `profile_membership_plan`:**以档位枚举为主键**,含标题、rank、月价、年价(独立可配置字段)、每期额度、模型权限、并发上限(`128` = 不设上限)、排序与上下架;一行一档,月 / 年共享每期额度。 +- `profile_recharge_product_config`:**删除** `tier` 与 `membership_period_points` / `membership_period_days` / `membership_queue_limit` / `membership_discount_bps`;删 4 行 `member_*`;充点复用旧 4 档 id 并置 `bonus_points = 0`,新增两档(`points_1280` / `points_3280`)。 +- 新增账本来源类型 `MembershipUpgradeGrant`;幂等锚 `membership-upgrade-grant:{user}:{order_id}`。 +- `profile_recharge_order` 末尾追加会员变更快照字段(变更前 / 后档位、周期、补点数、有效期、kind);快照字段全部用枚举,不用字符串。 +- schema 变更必须同步 `migration.rs`、表目录、生成绑定,并运行 `npm run check:spacetime-schema`; + 已有表新增字段一律放在结构体最后并带明确默认值。 +- 会员侧删列 / 删枚举属**破坏性变更**(会员未上线,已授权):迁移计划与回退见技术设计 §2.7; + 账本来源枚举仍只允许末尾追加 `MembershipUpgradeGrant`。 + +## 5. 接口契约 + +| 接口 | 变更 | +|---|---| +| `GET /api/profile/recharge-center` 等余额投影 | 并列三池;`monthlyPoints` 带到期日 / 刷新日 / 期数;不再暴露池语义的 `totalPoints` | +| `POST /api/profile/membership/upgrade-quote`(新增) | 只读报价:入参目标档位 + 目标周期,返回应付金额、补点、变更后月度余额、有效期;目标档位 token 严格解析进枚举 | +| 会员购买 / 升级下单 | **复用** `POST /api/profile/recharge/orders`;`product_id` = 目标计划 id,下单时重算并落订单快照 | +| `GET|POST /admin/api/profile/membership-plans`(新增) | 后台套餐配置,按档位枚举改月价 / 年价 / 每期额度 / 模型权限 / 并发上限 | +| `GET|POST /admin/api/profile/recharge-products` | 6 档充点商品的上下架与新档位 | + +`/api/external/v1` 不涉及本方案;若实施中触及其路由、DTO 或语义,必须在同一次变更中同步修订 +`docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 + +## 6. 里程碑 + +### M1 双余额与月度会员(不含年付与补差) + +- 交付物:三池并列投影(含 `monthlyPoints` 改名与 `totalPoints` 退役)、自然月账期、 + `profile_membership_plan`(档位枚举主键)与月度会员购买、充点 6 档(复用旧 id + 新增 2 档)、 + 会员档位枚举化与旧 `tier` / 旧权益字段退役。 +- 验收判据:§0 的判据 1、2、5;月付会员可购买、可到期清零、可在新期收到新额度。 +- 必测点:三池恒等式(总额 = 每日 + 月度 + 永久)、扣减顺序与可用总额、月末夹取(1/31 → 2/28 → 3/31)、 + 每日免费与月度同时存在时的扣减拆分元数据、退款跨期落永久池、旧充点商品历史订单仍可展示。 + +### M2 年付 12 期与补差升级 + +- 交付物:年付(12 期)、后端只读报价接口、`MembershipUpgradeGrant` 与订单维度幂等、 + 订单变更快照、后台套餐配置页。 +- 验收判据:§0 的判据 3、4;年付 12 期逐月发放;升级补付 / 补点与 §3.3 示例一致; + 重复回调不重复发点;退款进 `manual_review` 且快照可读。 +- 必测点:金额向上取到分 / 补点向下取整的边界(含 0.5 期、闰年、12/31 跨年)、 + 连续升级基准、报价与下单重算一致、支付失败不动原状态、年付到期未续清月度保留永久。 + +## 7. 风险与边界 + +- **永久余数是临时实现**:池层面的 bug 会表现为永久泥点虚高,排查必须回到账本 `permanentPointsDelta`(见 ADR 的 TODO)。 +- **在线契约变更**:`totalPoints` / `limitedPoints` 的消费方分布在后端投影、`packages/shared` 契约与 store、 + `PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`、AGC `AccountWallet` / `useAccountWallet`、 + admin-web 多处;漏改会静默显示错数字。必须一次性改完并全量跑相关测试。 +- **升级补点不可自动回收**:它只有「发」没有「扣」的对称半边,退款只能人工复核。 +- **惰性刷新没有 cron**:到期与时区问题只在用户被触碰时暴露,测试必须直接构造时间。 +- 跨天 / 跨周期退款仍然有损(落永久池),这是刻意行为,不要「顺手修」。 + +## 8. 待确认输入与遗留 TODO + +- **套餐目录数值已定**:`Starter` ¥39 / 400 点 / ¥390 · `Plus` ¥99 / 1150 / ¥990 · `Pro` ¥299 / 3650 / ¥2990 · `Max` ¥699 / 8650 / ¥6990(`Normal` 全 0);模型权限 Starter 仅基础、其余含高性能;并发 1 / 3 / 5 / 128(不设上限)。 +- **充点 6 档金额已定**:复用旧 4 档 id 并置赠送 0,新增 `points_1280`(¥128 / 1280 点)与 `points_3280`(¥328 / 3280 点);数值按原型 ¥6 / 18 / 30 / 68 / 128 / 328、¥1 = 10 泥点固化。 +- **TODO(永久泥点独立存储)**:见 `docs/adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md` 第 5 条。 +- **TODO(文档同步)**:实施时同批修订权威契约 + `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的「账户充值数据契约」与 + 「用户钱包与编辑器生成扣费契约」两节,以及 `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` 中 + 与扣费顺序相关的表述。 + +## 9. 需同步修订的权威文档 + +| 文档 | 修订点 | +|---|---| +| `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | 三池投影与扣减顺序、永久泥点来源、会员账期与年付、升级补点与幂等、充值商品档位与首充赠送、账本来源全集 | +| `docs/【项目基线】当前产品与工程约束-2026-05-15.md` | 「用户钱包与编辑器生成扣费契约」的余额字段 | +| `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | 与扣费顺序 / 月度池相关的表述 | +| `docs/project-memory/shared-memory/decision-log.md` | 本次四条决策(已追加 2026-10-02 条目) | +| `CONTEXT.md` | 新增「泥点与会员计费」术语 | diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index d14ae6087..eb2e3cc57 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -42,6 +42,7 @@ - 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 +- 2026-10-03 修订(模型权限):`GET /api/llm/models` 改为两个平行数组——`models` 只放本档可用;`unavailableModels` 放目录里其余(含后台停用与本档档位不够),元素带 `reason` 枚举(`plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`)。`unavailableModels` 是加法字段,旧客户端忽略后行为不变,且不放松服务端按档强制;`defaultModelId` 仍必须落在 `models` 内。客户端把 `unavailableModels` 渲染为不可点项并 hover / focus 出原因(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);已选模型落进不可用桶时自动回退默认并给出对应原因文案,后台直接删除(两桶都没有)用泛化文案。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。 - 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。 diff --git a/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md new file mode 100644 index 000000000..88a963eb5 --- /dev/null +++ b/docs/technical/【技术设计】会员与泥点前端改造-2026-10-03.md @@ -0,0 +1,342 @@ +# 【技术设计】会员与泥点前端改造 + +> 定位:承接 [`【技术设计】泥点三池与会员计费后端设计-2026-10-02`](./【技术设计】泥点三池与会员计费后端设计-2026-10-02.md)(后端契约与接口已落地) +> 与原型 [`充值与会员.html`](../../充值与会员.html),给出前端(`packages/shared` + `src` + `apps/ai-game-creator-shell`)的改造设计。 +> 范围:共享充值弹层、钱包入口、三处消费方的 controller 与客户端接口;不含后端实现。 +> 状态:**已实施(2026-10-03)**。评审已通过,按 §11 清单落地;实现与验证结果见 §11、§13。 + +--- + +## 0. 一句话 + +把共享充值弹层从「一列泥点商品」升级为与原型一致的「会员与泥点」双 Tab 弹层 +(套餐卡 + 月/年切换 + 会员状态 + 补差升级 + 六档充值 + 套餐对比 + 确认页), +金额与权益只读后端目录与报价接口,三处消费方共用同一套组件。 + +--- + +## 1. 已确认决策(拷问结论) + +| # | 决策 | 结论 | +|---|---|---| +| 1 | 改造层级 | 扩展**共享** `PlatformProfileRechargeModal` 及其 controller,三处消费方共用 | +| 2 | 与原型对齐 | **完全像素级对齐**:两个 Tab、四张套餐卡、对比表、数量估算、确认页都做 | +| 3 | 上线方式 | **直接全量开放**,不加功能开关;会员 UI 对所有用户可见 | +| 4 | 钱包入口 | 改用**原型入口形态**(可用总额 + 行内月度/永久 + 「会员与泥点」按钮) | +| 5 | 入口次级功能 | 保留「使用详情」「兑换码」为次级入口,不因贴原型而砍功能 | +| 6 | 余额池 | 弹层内显示**三池**(每日免费 / 月度 / 永久)并按真实扣减顺序说明 | +| 7 | 金额真源 | **服务端报价**:购买用目录价、升级调 `POST /api/profile/membership/upgrade-quote`,前端不计费 | +| 8 | 确认页 | **做**确认页;**不做**前端倒计时,也**不做**价格 compare / 不一致提示 | +| 9 | 支付方式 | **仅微信**,渠道按运行环境自动解析,不出现支付宝 | +| 10 | 套餐文案 | 价格/每期泥点/模型/并发/标题读后端 catalog;「数量估算」「推荐之选」等营销文案放前端常量 | +| 11 | 消费方范围 | Web 平台入口、图片编辑器、AGC 桌面壳**三处都接** | +| 12 | 文档流程 | 先出本设计文档评审,再动代码 | +| 13 | `review.txt` | 后端评审问题**不作为本次门禁**,本次只做前端 | + +--- + +## 2. 现状(已核实) + +- 共享弹层 `packages/shared/src/components/PlatformProfileRechargeModal/index.tsx`(538 行) + 只渲染 `center.pointProducts` 一列商品,标题「购买更多泥点」;props 只有 + `center / isLoading / error / submittingProductId / nativePayment / onClose / onRetry / onBuy / onConfirmNativePayment / onCloseNativePayment`。 +- 同目录 `index.test.tsx` 目前**断言不暴露会员**:`queryByRole('tablist')` 为 `null`、`queryByText('Starter')` 为 `null`, + 用例名即 `shows the point-product empty state without exposing membership purchase`。本次必须改这些断言。 +- Web 平台入口 `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` 与 + 图片编辑器 `src/components/image-editor/ImageCanvasEditorView.tsx` **共用**同一个 controller + `src/components/platform-entry/usePlatformProfileCenterController.ts`(1923 行)→ Web 端只需改一处。 +- AGC 桌面壳走独立链路:`apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts` + → `apps/ai-game-creator-shell/src/services/accountHost.ts`(Tauri `invoke`) + → `apps/ai-game-creator-shell/src-tauri/src/account_api.rs`(命令注册在 `desktop.rs`)。 +- 三端都**还没有**升级报价的客户端方法;Web 端现有客户端为 + `getPlatformProfileRechargeCenter` / `createPlatformProfileRechargeOrder(productId, paymentChannel)` / + `confirmWechatPlatformProfileRechargeOrder` / `watchWechatPlatformProfileRechargeOrder`。 +- 契约侧 `packages/shared/src/contracts/runtime.ts` 已有 + `ProfileMembershipPlan` / `ProfileMembershipCycleKind` / `ProfileMembershipPlanRecord` / + `ProfileMembership` / `ProfileMembershipUpgradeQuote` / `ProfileRechargeCenterResponse`。 +- 真实支付渠道只有微信(`wechat_mp` / `wechat_mp_virtual` / `wechat_jsapi` / `wechat_h5` / `wechat_native` / `mock`), + 由 `resolveProfileRechargePaymentChannel` 按运行环境解析,**没有支付宝**。 +- 会员下单复用 `POST /api/profile/recharge/orders`,`product_id = membership-{plan}-{cycleKind}` + (如 `membership-pro-yearly`),会员商品没有商品配置行,标题与价格来自目录表。 + +--- + +## 3. 契约与需要新增的前端接口 + +### 3.1 复用(不改) + +- `GET /api/profile/recharge-center` → `ProfileRechargeCenterResponse` + (`mudPointBalance` 三池 / `membership` / `membershipPlans` / `pointProducts`)。 +- `POST /api/profile/recharge/orders { productId, paymentChannel }` → 泥点与会员下单共用。 +- `POST /api/profile/recharge/orders/{orderId}/wechat/confirm` → 微信支付确认。 + +### 3.2 新增(前端缺、后端已有) + +Web 端 `src/services/platform-entry/platformProfileClient.ts` 新增: + +```ts +export function getPlatformProfileMembershipUpgradeQuote( + targetPlan: ProfileMembershipPlan, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/membership/upgrade-quote', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetPlan }), + }, + '读取升级报价失败', + options, + ); +} +``` + +AGC 桌面壳需要新增同名能力:`accountHost.ts` 增加 `getClientProfileMembershipUpgradeQuote(targetPlan)`, +并在 `src-tauri/src/account_api.rs` 新增 `#[tauri::command] read_profile_membership_upgrade_quote(target_plan: String)`, +在 `src-tauri/src/desktop.rs` 的命令清单里注册。这是桌面壳唯一的非纯前端改动。 + +### 3.3 会员下单 productId + +前端按 `membership-${plan}-${cycleKind}`(`yearly` / `monthly`)拼串后走既有下单接口, +不再向后端请求商品行;`plan === 'normal'` 不可下单。 + +--- + +## 4. 信息架构与组件拆分 + +弹层保持「一个 `index.tsx` + 内部子组件」的现有约定,另抽一个纯常量模块。 + +``` +PlatformProfileRechargeModal/ +├── index.tsx // Shell / Tab / 余额概览 / 套餐卡 / 周期切换 / 会员状态 / 对比表 / 充值网格 / 确认页 / 成功态 / 微信二维码 +├── membershipCopy.ts // 「数量估算」文案、推荐档位、省费标签等纯前端常量 +├── index.css // 复用现有 class,按需补样式 +└── index.test.tsx +``` + +信息架构(对齐原型): + +``` +顶部入口(PlatformMudPointWalletEntry) +└─ 弹层「会员与泥点」 + ├─ Tab:会员订阅 | 泥点充值 + ├─ 余额概览(三池:每日免费 / 月度 / 永久 + 各自失效时间) + ├─ 会员订阅面板 + │ ├─ 当前会员状态(非会员时隐藏) + │ ├─ 月付 / 年付切换(有效期内锁定为当前周期) + │ ├─ 套餐卡 × 4(Starter / Plus / Pro / Max,来自 catalog,enabled 过滤,normal 不出现) + │ ├─ 套餐对比表(4 行 × 4 档) + │ └─ 底栏主 CTA + ├─ 泥点充值面板 + │ ├─ 六档金额网格(¥6/18/30/68/128/328 ↔ 60/180/300/680/1280/3280 泥点,¥1 = 10 泥点) + │ └─ 底栏「充值 ¥X」 + └─ 确认页(弹窗) + ├─ 价格与权益明细(购买:目录价 + 首期额度;升级:补差 + 补点 + 有效期不变) + ├─ 支付方式(仅微信,自动解析渠道) + └─ 支付成功态 + └─ 微信扫码 / 跳转支付(复用现有 native 支付弹窗,不改) +``` + +### 4.1 新增 props(`PlatformProfileRechargeModalProps`) + +```ts +export type PlatformProfileMembershipConfirmState = { + /** 目标档位与周期;normal 不会出现在这里。 */ + plan: ProfileMembershipPlan; + cycleKind: ProfileMembershipCycleKind; + /** 首次购买时来自目录价;升级时来自 upgrade-quote。 */ + amountCents: number; + /** 服务端返回的补点;首次购买为 0。 */ + grantedPointsDelta: number; + /** 升级后的月度余额;首次购买为首期额度。 */ + monthlyBalanceAfter: number | null; + mode: 'purchase' | 'upgrade'; +}; + +// 追加到现有 props: +membershipQuote: ProfileMembershipUpgradeQuote | null; +isLoadingMembershipQuote: boolean; +membershipQuoteError: string | null; +membershipConfirm: PlatformProfileMembershipConfirmState | null; +onRequestMembershipQuote: (plan: ProfileMembershipPlan) => void; +onRequestMembershipPurchase: (plan: ProfileMembershipPlan, cycleKind: ProfileMembershipCycleKind) => void; +onCancelMembershipConfirm: () => void; +onConfirmMembershipPayment: () => void; +``` + +`onBuy(product)` 只服务泥点商品;会员走 `onRequestMembershipQuote` / `onRequestMembershipPurchase`。 + +--- + +## 5. 数据流与状态 + +- 套餐卡的价格、每期泥点、模型权限、并发上限、标题全部读 `center.membershipPlans` + (按 `sortOrder` 升序、`enabled` 过滤、排除 `normal`)。前端**不**存第二份价格。 +- 点套餐判定: + - 当前有效会员且 `plan !== 当前档位` 且 `cycleKind === 当前周期` → 调 `onRequestMembershipQuote(plan)`; + - 否则(非会员 / 已到期)→ 直接 `onRequestMembershipPurchase(plan, cycleKind)` 用目录价开确认页。 +- 周期切换:有效期内锁定为 `membership.cycleKind`,另一档禁用并提示「到期后可重新选择」。 +- 可升级判定用 `rank`(`center.membershipPlans`),高于当前才能升;同档与降档按钮禁用。 +- 确认页金额:升级取 `membershipQuote.amountCents`,购买取目录价;**不做**任何本地计费与价格比对。 +- 支付:确认页「去支付」→ 控制器用既有 `createPlatformProfileRechargeOrder(productId, channel)`, + 渠道走 `resolveProfileRechargePaymentChannel`;桌面/浏览器差异沿用现有 native 二维码弹窗。 +- 支付成功后刷新 recharge center;成功态展示本期泥点与三池余额。 + +--- + +## 6. 交互细则 + +- 与原型一致,但**去掉**原型里的本地 2 分钟报价倒计时与「报价已失效」逻辑 + (金额真源在后端,下单与支付确认由后端重算)。 +- **不做**价格 compare:不在前端比较「页面展示价」与「服务端返回金额」,也不做不一致提示。 +- 移动端优先:弹层宽度、套餐卡网格、对比表横向滚动,遵循现有 `PlatformProfileRechargeModalShell` 尺寸约定。 +- 可访问性:Tab 用 `role="tablist"` / `role="tab"` / `aria-selected` + 左右方向键; + 套餐卡与充值卡用 `role="radio"` + `aria-checked`;确认页与支付弹窗沿用 `role="dialog"`。 + +--- + +## 7. 文案与常量(`membershipCopy.ts`) + +只放后端没有、且属于营销表达的内容,按 `plan` token 匹配: + +- 每档「每月预计可完成」数量估算与说明; +- 「推荐之选」标签(原型为 `plus`); +- 年付「省 ¥X」文案(按目录 `yearPriceCents` 与 `monthPriceCents × 12` 计算,只用于展示); +- 并发文案:`concurrentJobLimit >= 128` 显示「不设套餐上限」,否则「同时运行 N 个独立任务」; +- 模型权限文案:`basic` →「基础模型」,`full` →「基础及高性能模型」。 + +未知 `plan` token 时给通用兜底文案,不抛错。 + +--- + +## 8. 三处消费方接入清单 + +| 消费方 | 文件 | 改动 | +|---|---|---| +| Web 平台入口 | `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx` | 传新增 props;入口按钮文案改「会员与泥点」 | +| 图片编辑器 | `src/components/image-editor/ImageCanvasEditorView.tsx` | 同上(共用 controller) | +| Web/编辑器 controller | `src/components/platform-entry/usePlatformProfileCenterController.ts` | 新增报价状态、确认状态、会员下单 handler | +| Web 客户端 | `src/services/platform-entry/platformProfileClient.ts` | 新增 `getPlatformProfileMembershipUpgradeQuote` | +| AGC 桌面壳 | `apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts`、`AccountWallet.tsx` | 新增报价状态与会员下单 handler | +| AGC 客户端 | `apps/ai-game-creator-shell/src/services/accountHost.ts` | 新增 `getClientProfileMembershipUpgradeQuote` | +| AGC Tauri | `apps/ai-game-creator-shell/src-tauri/src/account_api.rs`、`desktop.rs` | 新增并注册 `read_profile_membership_upgrade_quote` | +| 共享入口 | `packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx` | 入口形态按原型调整,保留次级入口 | + +--- + +## 9. 测试计划 + +- 共享弹层(`index.test.tsx`): + - 改写现有两条「不暴露会员」断言为「暴露会员 Tab 与套餐卡」; + - 新增:四档套餐按 `sortOrder` 渲染、`normal` 与 `enabled=false` 不出现; + - 新增:周期切换在有效期内锁定; + - 新增:点套餐触发 `onRequestMembershipQuote`; + - 新增:确认页展示金额与补点,且**不出现**倒计时与价格 compare 文案; + - 新增:六档充值网格与底栏充值按钮触发 `onBuy`/下单; + - 新增:三池余额展示(含每日免费重置值)。 +- Web controller:报价成功/失败、会员下单 productId 拼串、支付成功刷新。 +- AGC 壳:`accountHost` 新命令的 invoke 参数与 `useAccountWallet` 报价状态。 +- 回归:`PlatformEntryActiveFlowShell.test.tsx`、`ImageCanvasEditorView.test.tsx` 中与弹层标题/入口文案相关的断言同步更新。 +- 命令:定向 vitest + `npm run check:encoding` + `git diff --check`。 + +--- + +## 10. 验收判据 + +1. 打开「会员与泥点」弹层可见两个 Tab、四张套餐卡、周期切换、套餐对比表与六档充值网格。 +2. 套餐价格、每期泥点、模型权限、并发上限与后端 `membershipPlans` 完全一致,前端无第二份价格。 +3. 点套餐进确认页:购买显示目录价,升级显示服务端报价金额与补点;确认页无倒计时、无价格不一致提示。 +4. 支付方式只有微信,渠道按运行环境解析,不出现支付宝。 +5. 会员与泥点下单都走既有 `POST /api/profile/recharge/orders`,会员 productId 为 `membership-{plan}-{cycleKind}`。 +6. Web 平台入口、图片编辑器、AGC 桌面壳三处行为一致,入口保留「使用详情」「兑换码」。 +7. 三池余额(每日免费 / 月度 / 永久)与失效时间展示正确,弹层与入口不出现 `NaN`。 +8. `npm run check:encoding`、定向 vitest、`git diff --check` 通过。 + +--- + +## 11. 实施清单(按顺序) + +1. `packages/shared/src/contracts/runtime.ts`:如有缺字段补齐(本轮预计只读,不改形状)。 +2. `src/services/platform-entry/platformProfileClient.ts`:新增升级报价客户端方法。 +3. `packages/shared/.../PlatformProfileRechargeModal/membershipCopy.ts`:新增前端常量。 +4. `packages/shared/.../PlatformProfileRechargeModal/index.tsx`:双 Tab + 会员面板 + 充值网格 + 确认页。 +5. `packages/shared/.../PlatformMudPointWalletEntry/index.tsx`:入口形态按原型调整。 +6. `src/components/platform-entry/usePlatformProfileCenterController.ts`:报价/确认/下单状态与 handler。 +7. `PlatformEntryActiveFlowShell.tsx`、`ImageCanvasEditorView.tsx`:传新 props。 +8. AGC:`accountHost.ts` + `account_api.rs` + `desktop.rs` + `useAccountWallet.ts` + `AccountWallet.tsx`。 +9. 更新与新增测试,跑定向 vitest 与编码检查。 + +--- + +## 12. 风险与后续 + +- **后端已知缺陷**(见 `review.txt`):结算不比对 `order.amount_cents`、报价接口写库、 + 旧 `tier` 无迁移等。按用户决定**不作为前端门禁**;作为后端跟进项单独处理。 +- 确认页只展示服务端金额,不做事前拦截;若后端金额与展示不同,以支付确认结果为准。 +- AGC 桌面壳新增 Tauri 命令属于壳内 Rust 改动,需要与桌面端一起回归。 +- 「完全像素级对齐」意味着弹层代码量与测试量显著上升,后续可按效果评估是否精简对比表。 + +--- + +## 13. 实施与验证结果(2026-10-03) + +### 13.1 落地清单 + +- 共享弹层 `PlatformProfileRechargeModal`:双 Tab、三池余额概览、套餐卡、周期切换(有效期内锁定)、会员状态、套餐对比表、六档充值网格与底栏充值按钮、确认页(仅微信支付,无倒计时、无价格对比);新增 `membershipCopy.ts` 承载营销文案与并发/模型展示口径。 +- 钱包入口 `PlatformMudPointWalletEntry`:改为「可用总额 + 行内月度/永久 + 会员与泥点」,`使用详情` / `兑换码` 作为次级入口保留。 +- Web:`platformProfileClient.ts` 新增 `getPlatformProfileMembershipUpgradeQuote`;`usePlatformProfileCenterController` 新增报价状态与 `submitMembershipCheckout`;平台入口与图片编辑器已传参。 +- AGC 桌面壳:`accountHost.ts` 新增 `getClientProfileMembershipUpgradeQuote`;`account_api.rs` 新增并注册 `read_profile_membership_upgrade_quote`;`useAccountWallet` 新增报价状态与会员结算;`AccountWallet.tsx` 已传参。 + +### 13.2 验证证据 + +- `npm run typecheck` 通过(`tsconfig.typecheck-guardrails.json`)。 +- `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。 +- 定向 vitest:共享弹层 6/6;平台入口 22/22;图片编辑器顶栏 5/5;`usePlatformProfileCenterController.recharge` 8/8;AGC `accountHost` + `walletStore` 17/17。 +- `npm run check:encoding` 通过(5249 个文件);`git diff --check` 通过。 +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 +- ⚠ **未执行 `cargo check`**:仓库无 Rust 构建缓存,冷编译成本过高;新增 Tauri 命令只复用同文件既有 `require_session` / `build_client` / `bounded_business_id` / `request_json`,四个签名已逐一核对,风险集中在类型名拼写。 + +### 13.3 既有问题(非本次引入) + +- `ImageCanvasEditorView.test.tsx > shows the breakdown read failure without inventing rows` 在改动前的基线同样失败(已用 `git stash` 对照验证):弹层打开时仍处于 loading 分支,断言未等待错误态。属既有时序问题,本次未修改该行为。 + +--- + +## 14. 追加变更:每日免费发放额为 0 时隐藏(2026-10-03) + +### 14.1 语义 + +- **不引入** `retired` / 状态位 / 新字段;每日免费只是一个普通数值,发放额可以是 0。 +- **唯一信号**:`ProfileMudPointBalance.dailyFreeResetPoints <= 0`(后端每日发放额,来自后台配置 `daily_free_points_per_day`)。 +- **可逆**:后台把配置改回 > 0,前端下次读取自动恢复展示,无需额外开关。 +- **契约不变**:`ProfileMudPointBalanceResponse` 字段仍是必填 `u64`,`0` 本就合法;不改 `shared-contracts`、ts-rs 生成物与 OpenAPI。 +- **扣减与账本逻辑不变**:每日免费为 0 时不再产生发放账本(`profile.rs` 在 `amount_delta == 0` 时直接返回),创作扣点顺序仍为每日免费 → 月度 → 永久。 + +### 14.2 显示规则 + +``` +showDailyFreePool = dailyFreePoints > 0 || dailyFreeResetPoints > 0 +``` + +| 场景 | dailyFreePoints | dailyFreeResetPoints | 表现 | +|---|---|---|---| +| 当天正常用完 | 0 | 20 | 显示("每天重置为 20") | +| 每日免费发放额为 0 | 0 | 0 | 隐藏 | +| 配置中途改为 0,当天仍有存量 | > 0 | 0 | 显示(存量仍会被优先扣减,不能隐身) | + +判定收敛到 `packages/shared/src/utils/mudPoints.ts` 的纯函数,三端共享组件共用,消费方 controller 不改。 + +### 14.3 改动点 + +- 后端 `server-rs/crates/module-runtime/src/commands.rs`:`daily_free_points_per_day` 由"必须 > 0"放宽为 `0..=i64::MAX`;`errors.rs` 对应文案同步。 +- Admin Web `apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`:每日免费泥点允许填 0。 +- 共享层 `packages/shared/src/utils/mudPoints.ts`:新增 `shouldShowProfileDailyFreePool`。 +- 共享层 `PlatformMudPointWalletEntry`:为 0 时隐藏"每日免费泥点"行。 +- 共享层 `PlatformProfileRechargeModal`:池概览按可见池动态渲染(三列变两列)、隐藏扣点顺序提示中与每日免费相关的口径。 +- 账本来源文案 `daily_free_grant` / `daily_free_reset` 保留,历史流水不改写。 + +### 14.4 测试 + +- 删除"每日免费必然存在"的断言(`PlatformMudPointWalletEntry`、`ImageCanvasTopbarView`、`ImageCanvasEditorView`),改为在共享层新增"发放额为 0 隐藏 / 有存量仍显示"的行为用例。 +- 后端与 Admin 追加"允许 0"的用例。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md new file mode 100644 index 000000000..3d1abaf46 --- /dev/null +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -0,0 +1,657 @@ +# 【技术设计】泥点三池与会员计费后端设计 + +> 定位:承接 [`【实施计划】双余额泥点与会员补差升级-2026-10-02`](./【实施计划】双余额泥点与会员补差升级-2026-10-02.md) +> 与四份 ADR,给出**可直接评审**的后端 DB schema、账本与幂等、领域数据结构、HTTP 契约。 +> 范围:`server-rs/crates`(`spacetime-module`、`module-runtime`、`spacetime-client`、`api-server`、`shared-contracts`)。 +> 状态:**评审版(2026-10-02)**;两轮拷问结论已就地并入本文(见 §9)。 +> 在线边界:每日免费池、永久泥点口径、钱包账本、充点订单**在线**;会员侧未上线,可零迁移重做。 + +--- + +## 0. 一句话 + +把「一个总额 + 三视图」的钱包改造成对外**并列三池**(每日免费 / 月度 / 永久),并把会员侧整体重做: +**档位身份改为 Rust 枚举**、目录表**以该枚举为主键**、按开通日自然月计期、年付 12 期与补差升级; +存储层保持 `wallet_balance` 单一权威总额不变。 + +--- + +## 1. 硬约束(设计前提) + +1. **在线不可漂移**:`profile_dashboard_state.wallet_balance`、`profile_daily_free_points`、 + `profile_wallet_ledger` 的字段形状与账本 id 方案不动。 +2. **默认只追加、不重排**:已有表新增字段一律放结构体末尾并带明确 `#[default(...)]`。 + 本轮例外:**会员侧**经用户明确授权做一次破坏性变更(删列 + 删/换枚举),依据是会员侧无存量行, + 迁移计划见 §2.7;钱包侧与充点订单不允许破坏性变更。 +3. ⚠️ **枚举按变体索引编码**:删除或重排变体会让已落库的行错位。因此: + - 账本来源枚举 `RuntimeProfileWalletLedgerSourceType` 仍**只允许末尾追加** `MembershipUpgradeGrant`; + - `RuntimeProfileMembershipTier` **整体删除**,前提是会员侧零存量行(用户已确认)。 +4. **幂等靠稳定账本 id**;扣 / 退账本 id 前缀对称性必须保留。 +5. **金额与取整只有后端一处实现**,前端展示价不是真相源。 +6. **会员档位身份必须是 Rust 枚举、禁止字符串**(用户硬性要求)。对外 wire 只传枚举 token, + 服务端**严格解析**进枚举,不接受自由字符串。 + +--- + +## 2. 数据模型 + +### 2.1 变更总览 + +| 表 / 对象 | 动作 | +|---|---| +| `profile_dashboard_state` | 不动(唯一权威总额 `wallet_balance`) | +| `profile_daily_free_points` | 不动 | +| `profile_wallet_ledger` | 形状不动;**末尾追加**来源枚举变体 `MembershipUpgradeGrant` | +| `profile_membership` | **删除** `tier` / `cycle_period_days`;**新增** `plan`(枚举) / `cycle_index` / `cycle_count` / `cycle_kind` | +| `profile_recharge_product_config` | **删除** `tier` 与四个 `membership_*` 列;删 4 行 `member_*`;充点 6 档重播种 | +| `profile_recharge_order` | 末尾**追加**会员变更快照字段 | +| `profile_membership_plan`(新增) | 会员目录,**以档位枚举为主键** | +| 枚举 | 新增 `RuntimeProfileMembershipPlan` / `RuntimeProfileMembershipCycleKind` / `RuntimeProfileMembershipModelAccess` / `RuntimeProfileMembershipChangeKind`;**删除** `RuntimeProfileMembershipTier`;账本来源追加 `MembershipUpgradeGrant` | + +### 2.2 会员档位枚举 + 目录表 `profile_membership_plan`(已定) + +**档位身份 = 枚举**(不再是字符串档位 id): + +```rust +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileMembershipPlan { + Normal, // 0 非会员 + Starter, // 1 + Plus, // 2 + Pro, // 3 + Max, // 4 +} +``` + +**目录表用该枚举当主键**(用户要求:后台要按档位直接改价 / 改权益): + +```rust +#[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, +} +``` + +- **一行一档**:月付与年付共享同一 `period_points`(年付 = 12 次同额度发放)。 + `cycle_count` 由 `cycle_kind` 推导(`Monthly = 1` / `Yearly = 12`),不落目录字段。 +- **年价是独立可配置字段** `year_price_cents`(Q6),不再按 `月价 × 10` 推导。 +- **并发 `concurrent_job_limit: u32`,哨兵 `128` 表示「不设上限」**(Q5,明确不使用 `Option`)。 +- **模型权限 `model_access` 只新增字段,本期不实现任何限制**(Q4);服务端提交路径本期不拦截。 +- 旧会员目录(`member_starter` / `member_basic` / `member_pro` / `member_ultimate` 四行 + `module-runtime` 静态查表) + 随之退役,见 §2.8。 +- 种子数据(用户给定,`enabled`:`Normal = false`,其余 `true`): + +| plan | rank | 月价 | 年价 | 每期泥点 | 模型权限 | 并发上限 | +|---|---|---|---|---|---|---| +| `Normal` | 0 | 0 | 0 | 0 | `Basic` | 1 | +| `Starter` | 1 | ¥39 = 3900 | ¥390 = 39000 | 400 | `Basic` | 1 | +| `Plus` | 2 | ¥99 = 9900 | ¥990 = 99000 | 1150 | `Full` | 3 | +| `Pro` | 3 | ¥299 = 29900 | ¥2990 = 299000 | 3650 | `Full` | 5 | +| `Max` | 4 | ¥699 = 69900 | ¥6990 = 699000 | 8650 | `Full` | 128(不设上限 for display ) | + +- `Normal`(Q7 已确认):0 每期泥点、`ModelAccess::Basic`、并发 1、rank 0、不可购买。 + +### 2.3 `profile_membership` 列变更 + +| 列 | 动作 | 类型 / 默认 | 说明 | +|---|---|---|---| +| `tier` | **删除** | — | 旧枚举整体删除,不保留兼容列 | +| `cycle_period_days` | **删除** | — | 自然月规则取代固定 30 天 | +| `plan` | 新增 | `RuntimeProfileMembershipPlan`,默认 `Normal` | 权威档位身份;字段名 `plan`,**类型必须是枚举** | +| `cycle_index` | 新增 | `u32`,默认 `0` | 当前第几期(`1..=cycle_count`) | +| `cycle_count` | 新增 | `u32`,默认 `1` | 本次购买总期数(月付 1 / 年付 12) | +| `cycle_kind` | 新增 | `RuntimeProfileMembershipCycleKind`,默认 `Monthly` | 周期类型(落列,不靠 `plan` 反查) | + +`status` / `started_at` / `expires_at` / `updated_at` / `cycle_started_at` / `cycle_resets_at` / +`cycle_granted_points` / `cycle_remaining_points` 语义不变。 + +### 2.4 `profile_recharge_order` 追加列(会员变更快照) + +全部**末位追加 + 默认值**;快照字段一律用枚举,不用字符串。 + +| 列 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `membership_change_kind` | `Option` | `None` | `Purchase` / `Upgrade` | +| `membership_plan_before` | `Option` | `None` | 变更前档位 | +| `membership_plan_after` | `Option` | `None` | 变更后档位 | +| `membership_cycle_kind_before` | `Option` | `None` | 变更前周期 | +| `membership_cycle_kind_after` | `Option` | `None` | 变更后周期 | +| `membership_period_points_before` | `Option` | `None` | 变更前每期额度 | +| `membership_period_points_after` | `Option` | `None` | 变更后每期额度 | +| `membership_granted_points_delta` | `u64` | `0` | 本次补点(进月度池的点数);与报价侧 `ProfileMembershipUpgradeQuoteResponse.granted_points_delta` 一致 | +| `membership_cycle_index` | `Option` | `None` | 变更时所在期数 | +| `membership_expires_at_before` | `Option` | `None` | 变更前到期 | +| `membership_expires_at_after` | `Option` | `None` | 变更后到期 | +| `membership_cycle_resets_at_before` | `Option` | `None` | 变更前刷新日 | +| `membership_cycle_resets_at_after` | `Option` | `None` | 变更后刷新日 | +| `membership_price_breakdown_json` | `Option` | `None` | 报价明细(剩余完整月数 / 剩余比例 / 单价 / 取整),审计与人工复核用 | + +- `points_delta` 语义不变 = 「钱包里加了几个泥点」;会员购买 / 升级时 = 本次入月度池的点数。 +- 退款仍走 `manual_review`,快照只读,不自动回滚。 + +### 2.5 枚举增删 + +- 新增 `RuntimeProfileMembershipPlan { Normal, Starter, Plus, Pro, Max }`。 +- 新增 `RuntimeProfileMembershipCycleKind { Monthly, Yearly }`(全新,无历史数据)。 +- 新增 `RuntimeProfileMembershipModelAccess { Basic, Full }`(仅新增字段,本期不执行)。 +- 新增 `RuntimeProfileMembershipChangeKind { Purchase, Upgrade }`(订单快照用,替代字符串)。 +- `shared-contracts` 新增 wire 侧 `ProfileMembershipPlanToken` / `ProfileMembershipCycleKindToken` / + `ProfileMembershipStatusToken` / `ProfileMembershipModelAccessToken` / `ProfileMembershipChangeKindToken`, + 把 DTO 里原来的裸 `String`(后台 upsert 请求、后台会员详情、订单变更快照、会员 / 档位 / 报价响应) + 收口为 typed enum;与模块枚举通过 `From` 双向映射,JSON wire 值仍是小写 token。**入参** DTO + (`ProfileMembershipUpgradeQuoteRequest`、`AdminUpsertProfileMembershipPlanRequest`)反序列化保持严格, + 未知 token 即拒;**响应** DTO 的 token 字段挂 `deserialize_with` 做前向兼容,未知取值按 fail-open + 兜底到已知最高档(plan→`max`、status→`active`、cycle→`yearly`、model_access→`full`), + 避免 AGC Tauri shell 等 Rust 客户端因后端新增一个 token 就整包解析失败(权益与下单金额仍以后端重算为准)。 + 这 5 个 token 枚举统一 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` + 导出到 `packages/shared/src/contracts/generated/`,前端只 re-export,不再手写字符串联合类型。 +- **会员结构体也走生成类型(2026-10-03 补)**:`ProfileMembershipPlanResponse` / `ProfileMembershipResponse` / + `ProfileRechargeProductResponse` / `ProfileRechargeOrderResponse` / `ProfileRechargeCenterResponse` / + `ProfileMembershipUpgradeQuoteResponse` / `ProfileMembershipPlanAdminListResponse` / + `AdminUpsertProfileMembershipPlanRequest` / `ProfileDailyFreePointsResponse` / + `ProfileMembershipUpgradeQuoteRequest` 同样 `ts(export)`;64 位整数字段补 `ts(type = "number")` + 对齐前端 `number`(ts-rs 默认 `bigint`)。`packages/shared/src/contracts/runtime.ts` 与 + `apps/admin-web/src/api/adminApiTypes.ts` 改为 re-export 生成类型,只手写保留 `kind` / `status` + 这类前端收窄联合(用 `Omit` 组合),`ProfileRechargeCenterResponse` 同理收窄内层商品 / 订单数组。 +- **删除** `RuntimeProfileMembershipTier { Normal, Month, Season, Year, Starter, Basic, Pro, Ultimate }` + 及其 `as_str()`(会员侧零存量,用户已授权破坏性变更)。 +- `RuntimeProfileWalletLedgerSourceType` **末尾追加** `MembershipUpgradeGrant` + (`as_str = "membership_upgrade_grant"`),同步 `shared-contracts` 常量与后台来源筛选。 + +### 2.6 充点商品:复用旧 id + 新增 2 档(已定,不变) + +- 现状 4 档:`points_60` ¥6 / `points_180` ¥18 / `points_300` ¥30 / `points_680` ¥68(后三档首充赠送 50%)。 +- 决策:**复用旧 4 档 id**,把 `bonus_points` 置 0(取消首充赠送,文案同步去掉「首充加赠」), + 新增 `points_1280`(¥128 / 1280 点)与 `points_3280`(¥328 / 3280 点),共 6 档,基础比例仍 ¥1 = 10 泥点。 +- 旧行不删除:历史订单与首充资格按 `user_id + product_id` 引用仍可解读;同一 `product_id` 的规则随版本变化已在本文注明。 + +### 2.7 破坏性迁移计划(Q2 = (a),已获用户明确授权) + +**前提**:会员侧未上线 → `profile_membership` 无存量行;`profile_recharge_product_config` 是**种子配置**, +可整体重播种,不承载用户数据。 + +1. `profile_membership`:删 `tier` / `cycle_period_days`;加 `plan` / `cycle_index` / `cycle_count` / `cycle_kind`。 +2. `profile_recharge_product_config`:删 `tier` + `membership_period_points` / `membership_period_days` / + `membership_queue_limit` / `membership_discount_bps`;删 4 行 `member_*`;重播种 6 档充点。 +3. 新增表 `profile_membership_plan` 并写入 §2.2 种子。 +4. `profile_recharge_order` 末位追加 §2.4 快照列。 +5. `profile_wallet_ledger` 来源枚举末尾追加 `MembershipUpgradeGrant`。 +6. 同步 `server-rs/crates/spacetime-module/src/migration.rs`、后端架构文档表目录、生成绑定; + 运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 + +⚠️ **风险与回退**:SpacetimeDB 持久表的删列属破坏性迁移,自动迁移不保证支持。 +实施时必须用真实 `spacetime publish`(本地库)验证第 1、2 步;若平台拒绝原地删列,则 +**回退为「保留列 + 写死默认值 + 运行时不读」**,并在生成绑定与表目录里明确标注为废弃残留。 +两种路径都必须先让 schema 门禁通过,不得使用 `--delete-data=always` 绕过。 + +### 2.8 会员侧遗留清理清单 + +实施时**删除**(不是废弃),并同步修掉引用它们的测试: + +- `module-runtime`:`RuntimeProfileMembershipTier` 枚举、`runtime_profile_membership_tier_rank`、 + `runtime_profile_membership_period_points`、`runtime_profile_membership_queue_limit`、 + `runtime_profile_membership_discount_bps`、`runtime_profile_membership_benefits`、 + `RuntimeProfileMembershipBenefitSnapshot`、`runtime_profile_recharge_membership_products`、 + `PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS`、`build_membership_recharge_product` 中的会员分支。 +- `spacetime-module`:`canonical_membership_pricing_tier`、`canonical_membership_pricing_tier` 驱动的 + `membership_product_price_cents_by_tier`、`apply_active_membership_renew_row` 的固定天数推进、 + `membership_cycle_reset_at` 固定天数实现,以及 `membership_discount_bps` 折扣概念。 +- 数据与契约:`RuntimeProfileMembershipRecord` / `Snapshot` 的 `tier` / `cycle_period_days`、 + `RuntimeProfileRechargeProductSnapshot` / `Record` / `ConfigSnapshot` 的 `tier` 与四个 `membership_*` 字段、 + DTO `ProfileMembershipResponse.tier`、`ProfileRechargeProductResponse.tier`、后台商品 upsert 输入的 `tier`。 +- 测试:`legacy_membership_tiers_share_new_pricing_ranks`、`recharge_product_catalog_matches_reference_prices` + 中的会员档位断言等,改为按 §2.2 种子断言。 + +钱包 / 泥点 / 充点订单侧**不动**(`profile_wallet_ledger`、`profile_daily_free_points`、 +`profile_dashboard_state` 与 6 档充点商品除外)。 + +### 2.9 表定义所在文件更正 + +真正生效的表定义在 **`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`**: +`src/runtime.rs` 用 `#[path = "runtime/active/profile.rs"] mod profile;` 挂载。 +`server-rs/crates/spacetime-module/src/runtime/profile.rs` 是**未被引用的旧副本**, +实施前先确认它的处置方式(删除或与 `active/` 对齐),避免文档指向失效文件。 + +--- + +## 3. 领域数据结构(`module-runtime`) + +```rust +pub struct RuntimeProfileMembershipPlanSnapshot { + pub plan: RuntimeProfileMembershipPlan, + pub title: String, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub rank: u8, + pub cycle_count: u32, // 月付 1 / 年付 12,由 cycle_kind 推导 + pub price_cents: u64, // 按所选周期取 month/year 价 + pub period_points: u64, + pub model_access: RuntimeProfileMembershipModelAccess, + pub concurrent_job_limit: u32, +} + +pub struct RuntimeProfileMembershipCycleRef { + pub cycle_index: u32, + pub cycle_count: u32, + pub cycle_kind: RuntimeProfileMembershipCycleKind, + pub cycle_started_at_micros: i64, + pub cycle_resets_at_micros: i64, +} + +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, +} +``` + +目录纯函数(`membership_catalog`,无 IO): + +- `runtime_profile_membership_plan_catalog()`:后台 `profile_membership_plan` 表的种子真相源, + 一行一档(`title` / `rank` / `month_price_cents` / `year_price_cents` / `period_points` / + `model_access` / `concurrent_job_limit` / `enabled`)。目录展示顺序与升级次序统一由 `rank` 决定。 +- `resolve_runtime_profile_membership_plan_snapshot(plan, cycle_kind)`: + 「档位 + 周期 → 可下单套餐」的唯一解析入口,`price_cents` 取所选周期的价格字段。 +- `runtime_profile_membership_plan_rank(plan) -> Option` / `is_runtime_profile_membership_plan_purchasable(plan) -> bool`: + 升级比较与可购买判定的唯一入口;`rank` 缺行时返回 `None`(不再用 `unwrap_or(0)` 把缺失伪装成 `Normal`), + `Normal` 与下架档位都不可购买。 + +`RuntimeProfileMembershipSnapshot` / `RuntimeProfileMembershipRecord`: +`tier` → `plan`(枚举),删 `cycle_period_days`,新增 `cycle_index` / `cycle_count` / `cycle_kind`。 + +纯函数(无 IO,可单测): + +- `membership_cycle_window(anchor_micros, index) -> (start, reset)`: + 北京时间锚点 + 自然月推进 + 月末夹取,**从原始锚点重算**(1/31 → 2/28 → 3/31); + 窗口本身与周期类型无关,总期数由 `cycle_kind.cycle_count()` 给出,`expires_at` 取第 `cycle_count` 期的边界。 +- 账期推进基元(复用 `civil_calendar` 的公历换算,不读系统时区): + `advance_beijing_months_clamped(anchor, months)`、`beijing_local_date_parts(micros) -> (年月日, 当日微秒)`、 + `beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、 + `membership_cycle_remaining_micros(resets_at, now)`。 +- `quote_runtime_profile_membership_upgrade(input) -> Result`: + 入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号、 + 本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。 + 本次购买总期数**不是入参**:由目标周期类型推导(`target.cycle_kind.cycle_count()`,月付 1 / 年付 12), + 杜绝「入参期数与周期类型不一致」导致按错误期数计费。 +- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`(`cycle_count` 由上一步推导)、 + `cycle_started_at <= now`、`cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input` + (`InvalidQuoteInput`),不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。 +- `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`: + §3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。 +- `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。 +- **「后续完整月数」= `cycle_count − cycle_index`**(`cycle_count` 由周期类型推导),不从 `cycle_resets_at` 重新锚定推算: + 月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。 +- `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、 + `cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。 +- `is_unlimited_concurrency(limit) -> bool`:`limit >= 128` 视为不设上限(哨兵值集中在领域层一处)。 + +--- + +## 4. 写路径(SpacetimeDB) + +```mermaid +flowchart TD + A[下单 reducer: 购买 / 升级] -->|支付成功回调| B[应用会员权益] + B -->|purchase| C[发首期: plan, cycle_index=1, cycle_count] + B -->|upgrade| D[补点: cycle_remaining += delta
ledger=membership-upgrade-grant] + C --> E[membership-period-grant 账本] + F[带符号变动 / 读快照] -->|cycle_resets_at 到期| G[先清后发: cycle_index+1] + F -->|expires_at 到期| H[清月度, 停发, 保留永久] + G --> E + I[创作扣费] --> J[每日免费 → 月度 → 永久] +``` + +- **购买**:`apply_profile_membership_purchase`(改造)→ 写 `plan` / `cycle_index=1` / `cycle_count` / + 自然月窗口 / 发首期额度;账本 `membership-period-grant:{user}:{micros}:{plan}`(沿用)。 +- **换期**:`refresh_profile_membership_cycle`(改造)→ 自然月推进 `cycle_index+1`,先清上期再发新期; + `cycle_index >= cycle_count` 或 `expires_at` 到期 → 清月度、停发、保留永久。 + 账本沿用 `membership-period-reset:{user}:{reset_micros}`(`action = "reset"` / `"expire"`)。 +- **升级**:`apply_active_membership_upgrade`(改造,替换 `apply_active_membership_upgrade_row`)→ + `cycle_remaining += 补点`、`cycle_granted_points += 补点`、`plan = 目标`;账本 + `membership-upgrade-grant:{user}:{order_id}`(订单维度天然幂等),来源 `MembershipUpgradeGrant`。 +- **下单金额**:`resolve_profile_membership_order_amount_cents`(改造)→ 以 `plan` + `cycle_kind` 比较 + (替换按 `tier` 的 `resolve_active_membership_purchase_mode` / `membership_product_price_cents_by_tier`), + 升级金额改用 §6 的补差公式。 +- **扣减 / 退款回填**:**不改** `apply_profile_wallet_signed_delta` 的三池顺序(每日免费 → 月度 → 永久), + `cycle_resets_at` 仍是退款回填的对齐锚点;跨天 / 跨周期回填失败的金额继续落永久池。 +- 年付后续期换期的账本来源**沿用** `MembershipPeriodReset`(净额「先清后发」语义);期数放在 metadata,不新增枚举变体。 + +--- + +## 5. HTTP 契约 + +### 5.1 余额投影 `ProfileMudPointBalanceResponse`(重做,已落地) + +```jsonc +{ + "dailyFreePoints": 20, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-10-03T00:00:00+08:00", + "monthlyPoints": 615, + "monthlyResetsAt": "2026-11-20T09:00:00+08:00", + "permanentPoints": 1482 +} +``` + +- 删除:`totalPoints`(池语义的总额)、`limitedPoints`、`limitedExpiresAt`。 +- 三个池子都是**扁平标量**:月度泥点的档位、期序号、期总数等元数据挂在 + `ProfileMembershipResponse`(§5.2),余额投影只负责「各池还剩多少 + 各自什么时候失效」。 +- 决策:**不提供** `availableTotalPoints`;`walletBalance`(原始总额)从用户面移除,仅后台 / 内部对账使用。 +- 需要「合计」时由前端派生(`packages/shared/src/utils/mudPoints.ts` 的 + `resolveProfileMudPointBalanceView`),不把第二份口径放回后端。 + +### 5.2 会员信息 `ProfileMembershipResponse`(改,已落地) + +- 新增 `plan`(档位 token,如 `"pro"`;非会员与已到期统一为 `"normal"`)/ + `cycleKind`(`monthly` / `yearly`)/ `cycleIndex` / `cycleCount`。 +- **不提供** `planTitle`:展示标题属于目录表 `profile_membership_plan.title`, + 由充值中心的 `membershipPlans` 提供,避免同一档位在两个响应里各带一份可变标题。 +- **删除** `tier`(旧枚举整体退役,不再对外暴露)。 +- 删除 `cyclePeriodDays`(固定 30 天已被自然月账期取代)。 +- 保留 `status` / `startedAt` / `expiresAt` / `cycleStartedAt` / `cycleResetsAt` / + `cycleGrantedPoints` / `cycleRemainingPoints`。 + +### 5.3 升级报价(新增,只读,已落地) + +`POST /api/profile/membership/upgrade-quote` + +```jsonc +// request:只给目标档位;周期沿用当前会员周期(有效期内不支持月年互换) +{ "targetPlan": "pro" } +// response +{ + "currentPlan": "plus", + "targetPlan": "pro", + "cycleKind": "yearly", + "cycleIndex": 3, + "cycleCount": 12, + "amountCents": 158334, + "grantedPointsDelta": 1250, + "monthlyBalanceAfter": 1865, + "expiresAt": "...", + "cycleResetsAt": "...", + "remainingFullMonths": 9 +} +``` + +- 纯读、不落库;下单时后端按同一组输入重算并落订单快照(§2.4)。 +- 周期不接受入参:`targetCycleKind` 不在契约里,月转年 / 年转月是拒绝项(到期后重新选择)。 +- `targetPlan` 严格解析进 `RuntimeProfileMembershipPlan`,未知 token 直接 4xx。 +- **唯一解析器**:档位 / 周期 / 模型权限 / 变更类型的 wire token 一律经各自的 `parse()` 解析 + (`RuntimeProfileMembershipPlan::parse` 等),返回 `None` 即拒绝;禁止在各处手写字符串匹配。 +- 日期字段是 `expiresAt` / `cycleResetsAt`(RFC3339 字符串),不带 `*Micros` 双字段。 + +### 5.4 下单(复用现有链路,已落地) + +`POST /api/profile/recharge/orders { product_id, payment_channel }`。 +决策:**复用此单接口**;会员订单的 `product_id` 编码「档位 + 周期」: +`membership-{plan}-{cycleKind}`(如 `membership-pro-yearly`), +由 `runtime_profile_membership_product_id()` / `parse_runtime_profile_membership_product_id()` 双向解析。 + +- 会员商品**没有** `profile_recharge_product_config` 行:标题与价格都来自目录表, + 订单行按 `product_id` 现算商品快照(`build_membership_order_product`)。 +- 后端按当前会员状态区分购买(无有效会员 → 目录价)与升级(有效期内 → 补差重算), + 金额在创建订单与支付确认两处都走同一函数 `resolve_profile_membership_order_amount_cents`。 +- 支付确认时即使档位已下架,已支付订单仍按原档位结算(`resolve_profile_recharge_order_product` + 不做出售校验),避免「付款后档位被后台下架」导致权益丢失。 + +### 5.5 充值中心 `ProfileRechargeCenterResponse`(改,已落地) + +- `mudPointBalance` 用 §5.1;`membership` 用 §5.2。 +- 会员列表改为 `membershipPlans: RuntimeProfileMembershipPlanRecord[]`:一行一档,含 `plan` / + `title` / `rank` / 月价 / 年价 / 每期泥点 / 模型权限 / 并发上限 / 可购买, + 由 `profile_membership_plan` 表按 `rank` 升序读出。 +- 删除 `walletBalance`、`membershipProducts`、`benefits`(旧 Month/Season/Year 权益矩阵)。 +- `pointProducts` 只剩泥点商品(会员商品行不再出现在商品配置表里)。 + +### 5.6 后台(新增 / 改,已落地) + +- 新增 `GET|POST /admin/api/profile/membership-plans`(按档位枚举 CRUD 价格与权益)。 + - `rank` 不进请求:升级比较基准由代码内目录决定,后台不能改。 + - 目录不再有独立「排序」字段:展示顺序与升级次序统一由 `rank` 升序决定。 + - `normal` 是不可购买的非会员占位档位,价格与额度由后端按 0 落库、`enabled` 强制 `false`。 + - 非 `normal` 档位要求月价 / 年价 / 并发上限都大于 0,否则 4xx。 +- 后台页:新增「会员档位」页(`AdminMembershipPlanPage`);「充值商品」页收敛为纯泥点商品 + (`AdminRechargeProductPage` 不再有会员档位与 `membership_*` 字段)。 +- 订单列表 / 退款人工复核读取 §2.4 快照(`membershipChange`,含变更前后档位、周期、补点与有效期)。 +- 用户详情返回单用户会员状态与账期(档位 / 状态 / 账期类型与期数 / 本账期发放与剩余 / 到期 / 重置), + 钱包区块把月度池命名为「月度泥点」。 +- 通用表浏览器补上 `profile_membership_plan` 的中文标签与说明。 + +### 5.7 契约生成(ts-rs,已落地) + +- 余额 DTO 的形状由 Rust 单一真源导出,前端**不再手写第二份字段列表**: + `cargo test -p shared-contracts --features ts-bindings export_bindings` 生成 + `packages/shared/src/contracts/generated/ProfileMudPointBalanceResponse.ts`, + `packages/shared/src/contracts/runtime.ts` 只 re-export 成 `ProfileMudPointBalance`。 +- 会员 token(`profileMembershipPlanToken` / `profileMembershipCycleKindToken` / + `profileMembershipStatusToken` / `profileMembershipModelAccessToken` / + `profileMembershipChangeKindToken`)同样由 ts-rs 导出为字符串联合(5 个生成文件), + `packages/shared/src/contracts/runtime.ts` 与 `apps/admin-web/src/api/adminApiTypes.ts` + 改为生成类型别名 / re-export,删掉本地手写字面量。 +- `u64` 字段加 `ts(type = "number")`:JSON 走数字,不用 `bigint`,避免序列化与前端类型分叉。 +- `npm run check:generated-bindings` 以「重新生成并与工作区逐字节比较」做门禁,防止 Rust 改了而生成文件没重跑。 +- 需要展示用派生字段(如 `totalPoints` 合计)时,在前端 `utils/mudPoints.ts` 里派生,不改 DTO。 + +### 5.8 Profile 错误码契约(typed error,已全面落地) + +- Profile 链路的业务拒绝不再靠中文 `error_message` 分类,全部由 procedure 结果里的机器可读 + `error_code` 透传。 +- 会员链路:`module-runtime` 的 `RuntimeProfileMembershipErrorCode`(`UpgradeRejected` / + `NotMember` / `AlreadyActive` / `CycleKindLocked` / `MembershipExpired` / `StateChanged` / + `PlanNotPurchasable` / `PlanCatalogMissing` / `ProductIdUnparseable` / + `MembershipProductRetired` / `InvalidPlanConfig`)在拒绝点显式产出, + `RuntimeProfileMembershipUpgradeQuoteProcedureResult` / + `RuntimeProfileRechargeCenterProcedureResult` / `RuntimeProfileMembershipPlanAdminProcedureResult` + 末尾追加 `error_code: Option`(`error_message` 保留人读文案)。 +- 兑换码核销链路:`module-runtime` 的 `RuntimeProfileRewardCodeRedeemErrorCode`(`NotFound` / + `Disabled` / `NotStarted` / `Expired` / `UsesExhausted` / `NotAllowedForUser` / + `InvalidReward`)在拒绝点显式产出,`RuntimeProfileRewardCodeRedeemProcedureResult` 末尾同样追加 + `error_code`。 +- 后台兑换码管理链路:`module-runtime` 的 `RuntimeProfileRedeemCodeAdminErrorCode`(`NotFound` / + `InvalidField` / `AllowedUsersRequired`)在拒绝点显式产出, + `RuntimeProfileRedeemCodeAdminProcedureResult` / `RuntimeProfileRedeemCodeAdminListProcedureResult` + 末尾追加 `error_code`;字段校验错误统一归 `invalid_field`,私有码缺可兑换用户归 + `allowed_users_required`,停用不存在的码归 `not_found`。 +- 后台邀请码管理链路:`module-runtime` 的 `RuntimeProfileInviteCodeAdminErrorCode`(`InvalidField` / + `InUse`)在拒绝点显式产出,`RuntimeProfileInviteCodeAdminProcedureResult` / + `RuntimeProfileInviteCodeAdminListProcedureResult` 末尾追加 `error_code`;字段校验错误统一归 + `invalid_field`,邀请码被他人占用归 `in_use`。 +- `spacetime-client` 的 `SpacetimeClientError::ProcedureRejected { code, message }` 用 + `RuntimeProfileProcedureRejectionCode` 聚合四条链路的 typed code;`api-server` 的 `runtime_profile` + 映射直接读变体字段返回 400(`provider = runtime-profile`),`AppError.code` 取 + `code.as_str()`,基础设施错误保持 502。尚未接 typed code 的 procedure 字段校验类错误(如 + user_id 缺失)不产出 `error_code`,仍按字符串错误落到 502。 +- 行为变化:`会员目录缺少档位 ...`、「已迁移的会员商品」、兑换码核销的 `兑换码不存在` / + `已停用` / `未生效` / `已过期` / `次数已用完` / `不适用于当前账号` / `奖励无效`,后台兑换码 + 管理的 `兑换码不存在` / 入参校验失败 / 私有码缺可兑换用户,以及后台邀请码管理的入参校验失败 / + `邀请码已被其他用户占用` 原先落到 502,现在有明确错误码,统一 400。`api-server` 已删除 + `is_runtime_profile_redeem_code_domain_error` 文案匹配函数。 +- 兼容边界:procedure result 是 wire 结构,新增字段要求 module 与 api-server 同版本部署; + 旧 module 不返回 `error_code` 时,新 client 绑定反序列化会失败。 + +--- + +## 6. 计算规则(后端唯一真相源) + +- **自然月推进**:从原始开通日锚点出发,第 n 期 = 锚点 + n 个自然月(月末夹取),不走固定 30 天。 +- **月付**:`应付 = 新月价 − 当前月价`;`补点 = 新每期额度 − 当前每期额度`。 +- **年付**: + `应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余毫秒 / 本期实际毫秒)`; + `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。 +- **取整**:金额向上取到分,补点向下取整。 +- **档位比较**:用目录 `rank`,不用旧 `tier` 枚举排名。 +- **一致性**:报价接口与下单重算必须逐字段一致(契约测试)。 + +--- + +## 7. 兼容与迁移 / 在线边界 + +- 旧充点行不删除(复用 id 并去掉赠送);首充资格与历史订单继续可读。 +- 旧会员档位枚举与会员商品行**删除**(会员侧未上线,无需兼容)。 +- **不做** `tier → plan` 回填;若实施时发现意外存量会员行,先停下评估再补。 +- 会员目录播种(第 20 项结论 = 保留懒播种):`init` 只在首次创建库时跑,增量发布不重跑;「表存在但为空」的存量库靠读取 helper 懒播种,代价是只读入口首次调用会写库。若要彻底移除副作用,需先把播种改到发布期 migration,再删懒播种——当前不做。 +- 钱包侧(总额、每日免费、永久余数、账本)口径不允许漂移;充点订单不允许破坏性变更。 + +--- + +## 8. 测试矩阵 + +- 三池恒等式:`wallet_balance == daily_free + monthly + permanent`(构造式)。 +- 扣减拆分元数据 `dailyFreePointsDelta` / `membershipPeriodPointsDelta` / `permanentPointsDelta`。 +- 跨天 / 跨周期退款落永久池(不凭空补点)。 +- 自然月边界:1/31 → 2/28 → 3/31、闰年 2 月、12/31 跨年。 +- 档位目录:`plan` 枚举为主键、`Normal` 语义、`year_price_cents` 独立于月价、 + `concurrent_job_limit = 128` 判定为不设上限。 +- DTO 严格解析:非法 `plan` token 拒绝;响应不含 `tier`。 +- 升级报价 == 下单重算;订单维度幂等(重复回调不重复发点)。 +- 支付失败 / 关闭不动原状态;同套餐同周期拒绝下单。 +- 月付 / 年付到期未续:清月度、保留永久、停发。 + +--- + +## 9. 评审结论 + +| # | 问题 | 结论 | +|---|---|---| +| 1 | 会员目录 | 独立 `profile_membership_plan` 表,且**主键是档位枚举**(后台按档位改价 / 改权益) | +| 2 | 档位身份形态 | 必须是 Rust 枚举 `RuntimeProfileMembershipPlan`,绝不字符串;字段名 `plan` | +| 3 | 迁移路径 | **(a) 破坏性**:删两处 `tier` 列、删 `member_*` 行、干净定义新枚举;已授权覆盖「枚举只追加」 | +| 4 | `cycle_kind` | 落 `profile_membership` 列,不靠 `plan` 反查 | +| 5 | 对外档位 | 只给 `plan`;**删除** `tier`(不再保留兼容列 / 字段) | +| 6 | 模型限制 | 只新增 `model_access` 字段,**本期不实现限制** | +| 7 | 并发 | 不用 `Option`;`u32` 哨兵 `128` = 不设上限 | +| 8 | 年价 | 新增可配置字段 `year_price_cents`,不做 ×10 推导 | +| 9 | `Normal` 权益 | 0 每期泥点、`Basic`、并发 1、rank 0 | +| 10 | 会员遗留清理 | 全部删除(会员侧离线),见 §2.8 | +| 11 | 充点 6 档 | 复用旧 4 档 id 并把 `bonus_points` 置 0;新增 `points_1280` / `points_3280` | +| 12 | 年付换期账本 | 沿用 `MembershipPeriodReset` | +| 13 | 可用总额 | 不提供后端字段;前端按需相加 | +| 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` | +| 15 | `walletBalance` | 用户面不暴露 | + +> **2026-10-03 更新**:上表第 6、7 行的「本期不实现限制」由 §11 取代——`model_access` 与 `concurrent_job_limit` 进入服务端强制。 + +## 10. 评审输入与实施清单 + +### 10.1 已确认输入(本轮拍板) + +- **并发与模型权限**:只落字段、只用于展示,服务端本期不做拦截。 +- **`profile_recharge_product_config` 原地删列**:若 SpacetimeDB 本地发布拒绝删列,接受回退为「保留列 + 写死默认值 + 运行时不读」,并在生成绑定与表目录里标注为废弃残留。 +- **旧副本 `server-rs/crates/spacetime-module/src/runtime/profile.rs`**:确认删除(已删除)。 +- **档位命名**:列名与 wire 字段统一为 `plan`,不再使用 `plan_id` / `tier`。 + +### 10.2 实施状态 + +已全部落地(工作区可编译、测试全绿): + +后端: + +- 会员领域模块 `module-runtime/src/membership/{catalog,cycle,limits,upgrade}.rs`,公历基元抽为 `civil_calendar`。 +- 账本来源枚举末尾追加 `MembershipUpgradeGrant`,并同步契约常量、后台索引映射、前端来源标签与生成绑定。 +- 新增表 `profile_membership_plan`(主键为档位枚举 `RuntimeProfileMembershipPlan`),同步 `migration.rs`、表目录与生成绑定; + 播种策略:表为空时按代码内目录整表播种,表非空时保留后台改价。 + `#[reducer(init)] init_profile_membership_plan_catalog` 只在数据库首次创建时执行一次(增量发布不重跑), + 因此「表已存在但为空」的存量库由读取 helper `membership_plan_row` / `membership_plan_records` 里的 + `ensure_default_profile_membership_plan` 懒播种兜底。**副作用**:报价 / 充值中心 / 后台查询等只读入口在 + 目录为空的首次调用会写库,表非空后保持只读;这是有意保留的策略,见 §7。 +- 删列与清退:`profile_membership` 去掉 `tier` / `cycle_period_days`、追加 + `plan` / `cycle_index` / `cycle_count` / `cycle_kind`;`profile_recharge_product_config` 去掉 + `tier` 与四个 `membership_*` 字段;`RuntimeProfileMembershipTier`、会员权益矩阵、 + `canonical_membership_pricing_tier`、`membership_discount_bps`、固定 30 天常量全部退役。 +- `profile_recharge_order` 末位追加会员变更快照列(档位 / 周期 / 补点 / 有效期 / 价格拆分 JSON), + 购买与升级都会落快照;退款仍走人工复核,不自动回滚。 +- 自然月账期:`advance_profile_membership_cycle_to` 每期从原始锚点重算(1/31 → 2/28 → 3/31), + 到期清月度、停发、保留永久;6 档充点商品取消首充赠送,`¥128` / `¥328` 两档由停用改为在售。 +- HTTP:三池余额投影、`ProfileMembershipResponse.plan`、`POST /api/profile/membership/upgrade-quote`、 + `GET|POST /admin/api/profile/membership-plans`;会员订单复用 `POST /api/profile/recharge/orders`。 + +前端与契约: + +- `ProfileMudPointBalanceResponse` 由 ts-rs 生成到 `packages/shared/src/contracts/generated/`, + `packages/shared/src/contracts/runtime.ts` 只 re-export;`check:generated-bindings` 守住漂移。 +- 共享组件与平台入口改用三池:`PlatformMudPointWalletEntry` 明细面板并列「永久泥点 / 月度泥点 / 每日免费泥点」, + 充值弹层余额读三池合计,`ProfileRechargeCenterResponse` 不再有 `walletBalance`。 +- 展示用合计在 `packages/shared/src/utils/mudPoints.ts` 派生(`totalPoints`),月度刷新时刻固定按北京时间格式化。 +- 后台:新增「会员档位」页,充值商品页收敛为纯泥点商品;订单列表与退款人工复核渲染 + `membershipChange` 快照;用户详情展示会员状态与账期、钱包月度池改名「月度泥点」; + 表浏览器补 `profile_membership_plan` 标签。 + +未做(依决策保留,不是遗漏): + +- 模型权限与并发上限**只落字段**,服务端不做拦截。(2026-10-03 改:已由 §11 升级为服务端强制,本条作废。) +- 不提供 `availableTotalPoints`;合计由前端派生。 +- 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。 +- 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见 + [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。 + +## 11. 模型权限与并发上限的强制(2026-10-03 定案) + +> 承接 §9 第 6/7 行与 §10「未做」:把 `model_access` 与 `concurrent_job_limit` 从「只落字段、只用于展示」升级为**服务端强制**。 +> 范围:`module-runtime`(目录与判定纯函数)、`spacetime-module`(认领事务 + schema 索引)、`api-server`(模型代理)、`shared-contracts`、后台「AGC 模型」页。**不含 C 端 UI、不含灰度开关。** + +### 11.1 并发上限(只约束队列 job) + +- **强制点唯一**:`claim_external_generation_jobs_tx`(`spacetime-module/src/external_generation.rs`)是唯一的 `pending → running` 事务点。认领时按账号统计在飞数,未达上限才置 `running`;达上限则**跳过**该账号的候选任务——任务保持 `pending`,天然形成排队,无需新建队列。 +- **计入口径**:只算 `status = running`(含 lease 过期待回收的 `expired_running`)。失败退回 `pending` 等待重试的任务**释放**名额;`pending`(含 `available_at` 延时重试)不计入。 +- **回收豁免**:同一账号 lease 已过期的 `running` 被回收重认领时**不受自身上限限制**,否则超限账号的卡死任务永远无法回收。 +- **上限来源**:`profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`(后台权威);缺会员行 / 缺目录行**失败关闭**到 `Normal`(=1);`128` 哨兵 = 不设上限(复用 `is_unlimited_concurrency`)。 +- **计数实现**:给 `external_generation_job` 追加 `(owner_user_id, status)` 组合索引,事务内现算 `running` 行数;**不**新增计数表(`running` 行即真相)。 +- **覆盖范围**:本期只约束 `external_generation_job`;`/api/external/v1` 触发、挂在账号下的任务**同样计入**。 +- **排队深度**:**不设**单账号 `pending` 深度上限。 +- **⚠️ TODO(用户保留,后续单独设计)**:**直连同步生成不计入本期并发**——`/api/raw/v1/images/edit`(gpt-image-2,见 [`【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07`](./【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md))、`editor_project` 直连图片 / 图标 / 背景、`character_visual_assets` / `character_animation_assets`、`vector_engine_audio_generation` 等路径**没有** `external_generation_job` 行,`running` 计数覆盖不到。统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续设计。当前全局护栏仍只有 worker 并发、`max_concurrent_requests` 与 raw 解码信号量。 + +### 11.2 模型权限(只约束 AGC LLM 模型) + +- **字段**:`AgcModel`(`module-runtime/src/agc_models.rs`)末位追加权限档字段(`Basic` / `Full`),**缺省 `Basic`(失败开放)**;存量目录 JSON 缺该字段时按 `Basic` 解析。目录仍是一行 JSON + `revision` 乐观锁,非表。 +- **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。 +- **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。 +- **生效点**: + - `GET /api/llm/models`:返回 `models`(本档可用)与 `unavailableModels`(目录里其余,元素带 `reason` 枚举 `plan_required` / `disabled` / `unknown`;同时停用且档位不够取 `disabled`),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。分桶只补充展示信息,不放松下述强校验。 + - `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 + - `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。 + - 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。 +- **覆盖范围**:只约束 AGC 对话 / Agent 的 LLM 模型;图片 / 视频 / 音频等生成模型不纳入;`/api/external/v1` 不纳入。 + +### 11.3 共同约定 + +- **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。 +- 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。 + +### 11.4 实现落点(2026-10-03 编码约定) + +- **`module-runtime`**: + - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN` / `MODEL_UNAVAILABLE` / `MODEL_UNAVAILABLE_FOR_TIER`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(分别对应 422 `MODEL_UNAVAILABLE`、503 `MODEL_UNAVAILABLE_FOR_TIER`、403 `MODEL_NOT_AVAILABLE_FOR_PLAN`),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。 + - `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。 + - **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。 +- **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。 +- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,所选模型不可用返回 422 + `MODEL_UNAVAILABLE`,该档无可用模型返回 503 + `MODEL_UNAVAILABLE_FOR_TIER`(与单个模型不可用区分,客户端只按 code 也能分辨)。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。 +- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;`AdminAgcModel.access` 与 `AdminUpsertProfileMembershipPlanRequest.model_access` 一样用 `ProfileMembershipModelAccessToken`,未知 token 反序列化即拒;**不做**自动播种或代码内清单。 +- **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。 +- **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。 +- **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 +- **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。账号 AGC 模型权限改走只读 procedure `9e397fe9c`,`api-server` 切读 `b0dc94b46`。 + +--- + +> 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a460496e9..ec65f659b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -151,13 +151,21 @@ npm run check:server-rs-ddd 本地联调和人工排障不要使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`;发布目标显式传 `--server` / `--server-url`。 +## shared-contracts DTO 变更规则 + +1. 面向前端 / AGC 壳的**响应** DTO 只增不删:新增字段一律可选——`Option`,或非 `Option` 时带 `#[serde(default ...)]` 且默认值安全(空集合 / `0` / `false` / 最小合法值)。这样「客户端先于服务端发布」的滚动升级不会因为旧服务端缺字段而整包解析失败;这是兼容未来版本的手段,不是给当前旧客户端保留 legacy shim。 +2. 身份与状态类字段(例如 `ProfileMembershipResponse.plan` / `status`、`enabled`)保持必填,不给默认值:fail-open 会把付费会员误显示成非会员或反过来,静默错误比整包失败更难排查。 +3. 未知 token 值继续走 `deserialize_forward_compat_*` 兜底;它与「缺字段默认值」是两条独立路径,都要有契约测试(旧服务端缺字段、新服务端多发未知取值各一条)。 +4. 删除字段、改名、改类型或改必填属性属于破坏性变更,必须先确认版本化 / 迁移计划并更新权威文档;除非明确要求新旧客户端混跑,否则不为旧客户端保留 legacy shim。 +5. 响应 DTO 的 serde 属性不影响 ts-rs 绑定;字段集合或类型变化必须重新生成绑定并运行 `npm run check:generated-bindings`。 + ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 -2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 + 90 泥点 / 1800 分`、`points_300 = 300 + 150 泥点 / 3000 分`、`points_680 = 680 + 340 泥点 / 6800 分`。`points_60` 的首充赠送为 `0`;后三档首次购买分别赠送基础泥点的 `50%`。 -3. 后台通过 `/admin/api/profile/recharge-products` 读写充值商品配置;字段覆盖 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率、启用状态和排序。 +2. 默认泥点商品固定为四档:`points_60 = 60 泥点 / 600 分`、`points_180 = 180 泥点 / 1800 分`、`points_300 = 300 泥点 / 3000 分`、`points_680 = 680 泥点 / 6800 分`。所有档位的首充赠送一律为 `0`(已取消首充加赠)。 +3. 后台通过 `/admin/api/profile/recharge-products` 读写充点商品配置;写入口只接受 `productId`、标题、商品类型(仅 `points`)、金额分、基础泥点、徽标、说明、启用状态和排序。`bonus_points` / `duration_days` 以及旧的会员层级、会员每周期限时泥点、周期天数、队列上限、折扣率列都是退役列:表中保留以兼容存量 schema,回读固定为 `0`,不再进入 upsert 入参。 4. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。充值中心 BFF 还必须在 `mudPointBalance` 下发 `totalPoints`、`permanentPoints`、`limitedPoints`、`limitedExpiresAt`、`dailyFreePoints`、`dailyFreeResetPoints` 和 `dailyFreeResetsAt`;前端以该 read model 为真相源,不得自行用总额相减推算余额桶。当前版本公开 UI 只渲染不限时泥点和每日免费泥点,`limitedPoints` 与 `limitedExpiresAt` 仅保留给存量兼容和后端结算。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。 -5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。 +5. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。首充赠送当前恒为 `0`,因此该判定不改变前台展示和结算金额;兼容分支保留,待后续彻底移除首充赠送概念时一并删除。 6. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。 7. 当前版本公开充值 UI 只展示泥点商品,不渲染会员购买页签、会员商品、购买会员或升级会员入口。充值中心响应中的会员商品兼容字段、默认会员商品、`profile_membership` 和周期刷新逻辑继续保留;存量会员的 `cycle_remaining_points` 仍通过充值中心 read model 下发用于兼容和结算,但不作为限时泥点在当前版本前台展示。 8. 默认会员商品为空库播种时使用 `Starter / Basic / Pro / Ultimate` 四档,默认有效期均为 30 天,每周期限时泥点分别为 `200 / 800 / 2500 / 6000`,队列上限分别为 `2 / 2 / 5 / 10`。 @@ -292,6 +300,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;任务领取、续租、阶段更新和结果完成必须携带并校验同一 `worker_id + lease_token`。 +- 2026-10-03 会员并发上限:新增 btree 组合索引 `by_external_generation_job_owner_status (owner_user_id, status)`。`claim_external_generation_jobs_tx` 在 `pending → running` 前按任务 owner 的 `profile_membership.plan → profile_membership_plan.concurrent_job_limit` 现算该 owner `status = running` 的行数(含租约过期待回收的 `expired_running`),达上限的 pending 保持排队;账号自身过期的 running 回收豁免上限,避免超限账号的卡死任务无法回收。`pending` 不占名额,失败退回 `pending` 会自然释放名额;不新增计数表。缺会员行按 `Normal`(上限 1)失败关闭,目录行缺失同样按 1;`128` 哨兵由 `is_unlimited_concurrency` 解释为不限。 - 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。未登记的 source module 不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 @@ -733,144 +742,154 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_dashboard_state` - Rust 结构体:`ProfileDashboardState` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_daily_free_points` - Rust 结构体:`ProfileDailyFreePoints` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量读取 `profile_wallet_config.daily_free_points_per_day`(未配置时默认 `20`),`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过当前基础发放量,下一业务日首次触达时旧余额与叠加量一并失效并按当时最新配置重置。 ### `profile_feedback_submission` - Rust 结构体:`ProfileFeedbackSubmission` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_invite_code` - Rust 结构体:`ProfileInviteCode` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 生效时间:`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。 ### `profile_code_operation` - Rust 结构体:`ProfileCodeOperation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台兑换码 / 邀请码的持久操作记录,记录新增、更新、停用的码值、操作人和操作时间。 ### `profile_membership` - Rust 结构体:`ProfileMembership` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at/cycle_period_days` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点。 +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 作用:会员有效期和当前周期限时泥点事实源。`started_at/expires_at` 表示会员有效期,`cycle_started_at/cycle_resets_at` 表示当前周期,`cycle_granted_points/cycle_remaining_points` 表示当前周期已发放和剩余限时泥点;`plan/cycle_index/cycle_count/cycle_kind` 是档位身份与自然月期数的权威字段,末位追加。 +- 兼容列:旧 `tier`(`RuntimeProfileMembershipTier`)与固定 30 天的 `cycle_period_days` 已退役,仅为兼容存量 schema 保留在原始位置,运行时不读不写。 + +### `profile_membership_plan` + +- Rust 结构体:`ProfileMembershipPlan` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 作用:会员计划目录真相源,**主键是档位枚举** `RuntimeProfileMembershipPlan`(`Normal` / `Starter` / `Plus` / `Pro` / `Max`),一行一档,供后台按档位直接改价和改权益。 +- 字段:`month_price_cents` 与 `year_price_cents` 是两个独立可配置价格(年价不做月价 ×10 推导);`period_points` 是每期(一个自然月)额度,年付 = 12 次同额度发放;`model_access` 与 `concurrent_job_limit` 是档位权益,并发上限 `128` 表示“不设上限”。 +- 索引:主键 `plan`。 +- 说明:`Normal` 是不可购买的非会员占位档位(`enabled = false`);模型权限与并发上限当前只落字段、只用于展示,服务端不做拦截。 ### `profile_recharge_product_config` - Rust 结构体:`ProfileRechargeProductConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:泥点和会员充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示四档泥点商品,会员商品配置留存但不公开购买或升级入口。 -- 字段补充:会员商品追加 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps`;泥点商品这些字段必须为 `0`。 +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` +- 作用:泥点充值商品配置真相源,供充值中心展示、下单校验、支付确认和后台“充值商品”页维护;当前公开充值中心只展示六档泥点商品(`points_60` ~ `points_3280`),会员购买已下线。 +- 兼容列:旧 `tier`(`RuntimeProfileMembershipTier`)与会员商品权益列 `membership_period_points`、`membership_period_days`、`membership_queue_limit`、`membership_discount_bps` 已退役,仅为兼容存量 schema 保留在原始位置,运行时不读不写。 ### `profile_played_world` - Rust 结构体:`ProfilePlayedWorld` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_recharge_order` - Rust 结构体:`ProfileRechargeOrder` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:账户充值订单事实源。`status` 包含 `pending`、`paid`、`failed`、`closed`、`refunded`、`expired`;过期补偿字段 `expired_at`、`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于记录本地过期和微信查单结果。 ### `profile_recharge_refund` - Rust 结构体:`ProfileRechargeRefund` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。管理员读取契约同步暴露微信交易号、订单总额和人工复核获批错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。 - 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_status_updated_at`。 ### `profile_recharge_refund_observation` - Rust 结构体:`ProfileRechargeRefundObservation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、按“来源 + 事实指纹”稳定生成的 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;同一事实从不同来源进入时使用不同 ID,不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 - 索引:`by_profile_recharge_refund_observation_out_refund_no`、`by_profile_recharge_refund_observation_order_id`。 ### `profile_recharge_order_refund_settlement` - Rust 结构体:`ProfileRechargeOrderRefundSettlement` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:原充值订单维度的退款与权益结算摘要,保存累计成功退款金额、目标/已回收/未回收泥点、权益状态和钱包冻结事实。部分退款不改订单终态;累计全额才把订单改为 `refunded`。 - 索引:主键 `order_id`,`by_profile_recharge_order_refund_settlement_user_id` 用于钱包消费前检查退款欠款。 ### `profile_recharge_refund_hold` - Rust 结构体:`ProfileRechargeRefundHold` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台主动退款调用微信前的永久泥点占用。活动占用保护退款所需泥点但不改钱包总额;匹配退款成功后结算,关闭或确认未创建的退款释放。相同 `out_refund_no` 只允许内容完全一致的幂等重放。 - 索引:主键 `out_refund_no`,`by_profile_recharge_refund_hold_order_id`、`by_profile_recharge_refund_hold_user_id`、`by_profile_recharge_refund_hold_status`。 ### `profile_wallet_manual_restriction` - Rust 结构体:`ProfileWalletManualRestriction` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:后台人工钱包冻结当前态,保存冻结原因、创建/更新管理员和时间。它不承载退款欠账;退款欠账仍来自订单退款 settlement,两个来源任一有效都阻断普通消费。 - 索引:主键 `user_id`。 ### `profile_recharge_refund_bill_checkpoint` - Rust 结构体:`ProfileRechargeRefundBillCheckpoint` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:按交易账单日期记录已完成的退款 reconciliation,保存账单 SHA1(确认稳定无账单时保存 `NO_STATEMENT_EXIST`)、处理退款行数和完成时间。任一退款行失败时不写完成 checkpoint,成功前缀依赖 observation 幂等重放;重复下载、多实例执行或进程重启不会重复回收权益。 ### `profile_recharge_order_expiration_schedule` - Rust 结构体:`ProfileRechargeOrderExpirationSchedule` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:旧普通微信充值订单到期查单调度表,保留 schema 兼容但当前不再写入;当前过期路径改由原生 scheduled 表 `profile_recharge_order_expiration_timer` 触发。 ### `profile_recharge_order_expiration_timer` - Rust 结构体:`ProfileRechargeOrderExpirationTimer` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:充值订单原生 scheduled 表,`scheduled_at` 到点触发 `expire_profile_recharge_order_timer`;`order_id` 唯一,支付成功、本地关闭或 scheduled reducer 执行后删除对应行。 ### `profile_redeem_code` - Rust 结构体:`ProfileRedeemCode` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 生效时间:复用邀请码时间窗口语义,`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。用户兑换时以后端接收的 `redeemed_at_micros` 判定:未到开始时间拒绝为“兑换码未生效”,到达或超过截止时间拒绝为“兑换码已过期”。 - 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。`reward_points` 是奖励泥点(兑换成功后按 `redeem_code_reward` 流水进入泥点钱包),不是金额;后台奖励输入与列表必须带泥点单位。 ### `profile_redeem_code_usage` - Rust 结构体:`ProfileRedeemCodeUsage` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_referral_relation` - Rust 结构体:`ProfileReferralRelation` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_save_archive` - Rust 结构体:`ProfileSaveArchive` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_config` - Rust 结构体:`ProfileTaskConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_progress` - Rust 结构体:`ProfileTaskProgress` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_task_reward_claim` - Rust 结构体:`ProfileTaskRewardClaim` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `profile_wallet_ledger` @@ -888,7 +907,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `asset_operation_wallet_settlement` - Rust 结构体:`AssetOperationWalletSettlement` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 说明:资产操作 consume/refund 配对结算事实表,主键为 consume ledger ID,并保存配对 refund ledger、用户、金额和结算时间。退款先到且 consume 尚不可见时,该表作为持久化取消 intent;迟到 consume 必须检测该行并拒绝扣费,避免 worker 崩溃重领期间双扣。 - 索引:主键 `consume_ledger_id`。 @@ -902,18 +921,18 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_wallet_config` - Rust 结构体:`ProfileWalletConfig` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 作用:账号钱包全局配置真相源,当前维护新账号注册初始泥点数;表为空时业务回退 `100` 泥点。 ### `public_work_like` - Rust 结构体:`PublicWorkLike` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### `public_work_play_daily_stat` - Rust 结构体:`PublicWorkPlayDailyStat` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` ### api-server 长期订阅读模型 @@ -949,7 +968,7 @@ private `asset_object` 不进入长期订阅;安全判断通过受限 procedur ### `tracking_daily_stat` - Rust 结构体:`TrackingDailyStat` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。 ### `agc_tracking_event` @@ -963,7 +982,7 @@ private `asset_object` 不进入长期订阅;安全判断通过受限 procedur ### `tracking_event` - Rust 结构体:`TrackingEvent` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。 - 外部 API 失败:`event_key = external_api_call_failure` 使用同一张表落库;它是供应商失败审计事实,不新增 SpacetimeDB 表,查询时按 `module_key = 'external-api'` 或 `scope_kind = module AND scope_id = ''` 过滤。 diff --git a/packages/shared/src/components/GameDetailDisplay/index.test.tsx b/packages/shared/src/components/GameDetailDisplay/index.test.tsx index 190b8055f..a23985f82 100644 --- a/packages/shared/src/components/GameDetailDisplay/index.test.tsx +++ b/packages/shared/src/components/GameDetailDisplay/index.test.tsx @@ -23,7 +23,8 @@ describe('GameDetailDisplay', () => { screenshotObjectKeys={['screenshot/one.png']} assetUrls={{ 'cover/alpha.png': 'https://assets.example.test/cover/alpha.png', - 'screenshot/one.png': 'https://assets.example.test/screenshot/one.png', + 'screenshot/one.png': + 'https://assets.example.test/screenshot/one.png', }} controlsLabel="键盘" versionLabel="v2" diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx index 6925cf4a3..7ead4be93 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.test.tsx @@ -8,10 +8,9 @@ import { formatMudPointCount } from '../../utils/format'; import { PlatformMudPointWalletEntry } from './index.tsx'; const breakdown = { - totalPoints: 207, permanentPoints: 100, - limitedPoints: 80, - limitedExpiresAt: '2026-07-06T16:00:00Z', + monthlyPoints: 80, + monthlyResetsAt: '2026-07-06T16:00:00Z', dailyFreePoints: 27, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-12T16:00:00Z', @@ -22,7 +21,7 @@ test('formats mud point counts consistently', () => { expect(formatMudPointCount(12_345, true)).toBe('1.2万'); }); -test('shows only permanent and daily free points in the shared wallet panel', async () => { +test('shows permanent and monthly points in the shared wallet panel', async () => { const user = userEvent.setup(); const onRequestDetails = vi.fn(); const onRecharge = vi.fn(); @@ -38,28 +37,80 @@ test('shows only permanent and daily free points in the shared wallet panel', as />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); await user.hover(balanceButton); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); expect(details.className).toContain('rounded-[1.25rem]'); - expect(within(details).getByText('不限时泥点')).toBeTruthy(); - expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); + expect(within(details).getByText('不过期,创作照常扣点')).toBeTruthy(); expect(within(details).getByText('100')).toBeTruthy(); + expect(within(details).getByText('80')).toBeTruthy(); + expect( + within(details).getByText('2026-07-07 00:00 清零并发放新额度'), + ).toBeTruthy(); expect(within(details).queryByText('限时泥点')).toBeNull(); - expect(within(details).queryByText('2026-07-07 到期')).toBeNull(); - expect(within(details).getByText('每日免费泥点')).toBeTruthy(); - expect(within(details).getByText('27')).toBeTruthy(); - expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy(); expect(onRequestDetails).toHaveBeenCalledTimes(1); await user.click(within(details).getByRole('button', { name: '使用详情' })); expect(onOpenLedger).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: '充值' })); + await user.click(screen.getByRole('button', { name: '会员与泥点' })); expect(onRecharge).toHaveBeenCalledTimes(1); }); +test('hides the daily free row when the daily grant is zero', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.hover(screen.getByRole('button', { name: /可用总额 100,月度/ })); + + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); + expect(within(details).queryByText('每日免费泥点')).toBeNull(); + expect(within(details).getByText('永久泥点')).toBeTruthy(); + expect(within(details).getByText('月度泥点')).toBeTruthy(); +}); + +test('keeps the daily free row while the same-day remainder is still spendable', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.hover(screen.getByRole('button', { name: /可用总额 115,月度/ })); + + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); + expect(within(details).getByText('每日免费泥点')).toBeTruthy(); + expect(within(details).getByText('15')).toBeTruthy(); +}); + test('shows the redeem-code entry directly in the wallet bar when provided', async () => { const user = userEvent.setup(); const onRedeemCode = vi.fn(); @@ -90,12 +141,18 @@ test('keeps the chip on the live balance when cached details are stale', () => { , ); - expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: /可用总额 207,月度/ }), + ).toBeTruthy(); rerender(); - expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull(); + expect( + screen.getByRole('button', { name: /可用总额 195,月度/ }), + ).toBeTruthy(); + expect( + screen.queryByRole('button', { name: /可用总额 207,月度/ }), + ).toBeNull(); }); test('refreshes cached balance details while open and after reopening', () => { @@ -110,7 +167,9 @@ test('refreshes cached balance details while open and after reopening', () => { />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); fireEvent.mouseEnter(balanceButton); expect(onRequestDetails).toHaveBeenCalledTimes(1); @@ -124,11 +183,15 @@ test('refreshes cached balance details while open and after reopening', () => { />, ); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); expect(onRequestDetails).toHaveBeenCalledTimes(2); fireEvent.pointerDown(document.body); - fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' })); + fireEvent.mouseEnter( + screen.getByRole('button', { name: /可用总额 195,月度/ }), + ); expect(onRequestDetails).toHaveBeenCalledTimes(3); }); @@ -146,7 +209,7 @@ test('requests missing details after the opening balance load completes', () => , ); - fireEvent.click(screen.getByRole('button', { name: '泥点 20' })); + fireEvent.click(screen.getByRole('button', { name: /可用总额 20,月度/ })); expect(onRequestDetails).not.toHaveBeenCalled(); rerender(); @@ -167,25 +230,33 @@ test('keeps the desktop panel open while moving across the gap without click pin />, ); - const balanceButton = screen.getByRole('button', { name: '泥点 207' }); + const balanceButton = screen.getByRole('button', { + name: /可用总额 207,月度/, + }); const root = balanceButton.closest('.platform-mud-point-wallet-entry'); expect(root).toBeTruthy(); fireEvent.mouseEnter(balanceButton); - const details = screen.getByRole('dialog', { name: '泥点账户详情' }); + const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' }); fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); fireEvent.mouseEnter(details); act(() => vi.advanceTimersByTime(120)); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); balanceButton.focus(); fireEvent.click(balanceButton); - expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy(); + expect( + screen.getByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeTruthy(); fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null }); act(() => vi.advanceTimersByTime(120)); - expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull(); + expect( + screen.queryByRole('dialog', { name: '会员与泥点账户详情' }), + ).toBeNull(); } finally { vi.useRealTimers(); } @@ -207,7 +278,7 @@ test('requests the balance breakdown when a compact entry opens', async () => { />, ); - await user.click(screen.getByRole('button', { name: '泥点 20' })); + await user.click(screen.getByRole('button', { name: /可用总额 20,月度/ })); expect(onRequestDetails).toHaveBeenCalledTimes(1); expect(screen.getByText('余额明细暂不可用')).toBeTruthy(); }); diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 69519586f..1880bd60c 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -11,6 +11,11 @@ import { import type { ProfileMudPointBalance } from '../../contracts/runtime'; import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png'; import { formatMudPointCount } from '../../utils/format'; +import { + formatMudPointResetAt, + resolveProfileMudPointBalanceView, + shouldShowProfileDailyFreePool, +} from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { balance: number | null; @@ -55,7 +60,7 @@ function MudPointBalanceRow({ export function PlatformMudPointWalletEntry({ balance, - breakdown, + breakdown: rawBreakdown, isLoading = false, error = null, variant = 'desktop', @@ -71,6 +76,9 @@ export function PlatformMudPointWalletEntry({ const closeTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const isCompact = variant === 'mobile'; + const breakdown = rawBreakdown + ? resolveProfileMudPointBalanceView(rawBreakdown) + : null; const displayedBalance = balance ?? breakdown?.totalPoints ?? null; const balanceLabel = displayedBalance === null @@ -79,6 +87,15 @@ export function PlatformMudPointWalletEntry({ const exactBalanceLabel = displayedBalance === null ? '--' : formatMudPointCount(displayedBalance); const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`; + const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt); + const monthlyInlineLabel = formatMudPointCount( + breakdown?.monthlyPoints ?? 0, + true, + ); + const permanentInlineLabel = formatMudPointCount( + breakdown?.permanentPoints ?? 0, + true, + ); const cancelPendingClose = useCallback(() => { if (closeTimerRef.current !== null) { @@ -183,7 +200,9 @@ export function PlatformMudPointWalletEntry({ - 充值 + 会员与泥点 {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 a18adce79..dd81ffc83 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 eee7978ed..e00ad4071 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' @@ -116,16 +145,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' @@ -134,88 +161,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-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index 9f59ba38d..459f11fac 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -332,12 +332,16 @@ function insertedKeys(body) { const rust = rustDefinitions( fs.readFileSync(RUST_FILE, 'utf8') + '\n' + - fs.readFileSync(ADMIN_RUST_FILE, 'utf8') + '\n' + fs.readFileSync(CREATOR_RUST_FILE, 'utf8'), + fs.readFileSync(ADMIN_RUST_FILE, 'utf8') + + '\n' + + fs.readFileSync(CREATOR_RUST_FILE, 'utf8'), ); const ts = tsDefinitions( fs.readFileSync(TS_FILE, 'utf8') + '\n' + - fs.readFileSync(ADMIN_TS_FILE, 'utf8') + '\n' + fs.readFileSync(CREATOR_TS_FILE, 'utf8'), + fs.readFileSync(ADMIN_TS_FILE, 'utf8') + + '\n' + + fs.readFileSync(CREATOR_TS_FILE, 'utf8'), ); const failures = []; const mappedRust = new Set(PAIRS.map(([rustName]) => rustName)); 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 33140ee88..bfaba8290 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2239,6 +2239,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" => { @@ -4776,6 +4777,7 @@ fn wallet_ledger_source_type_to_string(value: &Value) -> Option { 13 => "daily_free_reset", 14 => "recharge_refund_recovery", 15 => "llm_router_consume", + 16 => "membership_upgrade_grant", _ => return Some(Value::Array(items.to_vec()).to_string()), } .to_string(), @@ -5099,8 +5101,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::{ @@ -6287,6 +6290,14 @@ mod tests { wallet_ledger_source_type_to_string(&json!([15, []])).as_deref(), Some("llm_router_consume") ); + 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("[17,[]]") + ); } #[test] @@ -7445,6 +7456,11 @@ mod tests { Method::GET, "/admin/api/profile/recharge-products", ), + ( + "membership-plans", + Method::GET, + "/admin/api/profile/membership-plans", + ), ( "recharge-orders", Method::GET, @@ -7469,6 +7485,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 5f8f96d78..ee069594f 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, @@ -55,6 +53,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, @@ -64,7 +63,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, @@ -214,6 +215,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 + } } } @@ -240,6 +244,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, @@ -254,15 +296,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( @@ -1085,12 +1121,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() @@ -1101,15 +1131,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, @@ -1128,6 +1151,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, @@ -1372,40 +1461,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 { @@ -1667,23 +1745,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() @@ -1711,10 +1800,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, @@ -1828,11 +1913,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, @@ -1840,22 +1924,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 @@ -1869,12 +1950,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 @@ -1885,13 +1967,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, } } @@ -1920,27 +2001,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, } } @@ -2193,11 +2273,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, @@ -2300,20 +2375,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), @@ -2357,19 +2419,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, @@ -2436,11 +2485,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::{ @@ -2456,13 +2509,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 @@ -2484,6 +2568,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] @@ -2548,7 +2749,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()), @@ -2561,11 +2762,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(), @@ -2573,13 +2775,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"); @@ -2635,6 +2836,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] @@ -3394,10 +3601,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(), @@ -3423,7 +3630,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, @@ -3436,26 +3643,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(), @@ -3476,8 +3680,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()); @@ -3539,7 +3743,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, @@ -3552,7 +3756,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(), @@ -3564,14 +3770,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(), @@ -3599,7 +3799,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()), @@ -3622,12 +3822,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(), @@ -3653,7 +3853,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, @@ -3666,26 +3866,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(), @@ -3697,9 +3894,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] @@ -3848,7 +4045,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, @@ -3861,7 +4058,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(), @@ -3873,14 +4072,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 44148900c..1569ce7d2 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, } impl RuntimeProfileWalletLedgerSourceType { @@ -1173,6 +1176,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::DailyFreeReset => "daily_free_reset", Self::RechargeRefundRecovery => "recharge_refund_recovery", Self::LlmRouterConsume => "llm_router_consume", + Self::MembershipUpgradeGrant => "membership_upgrade_grant", } } } @@ -1227,6 +1231,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 { @@ -1240,21 +1248,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 { @@ -1387,11 +1821,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))] @@ -1406,11 +1835,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, @@ -1419,26 +1843,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, @@ -1446,7 +1856,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))] @@ -1618,6 +2066,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))] @@ -1638,8 +2110,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, @@ -1659,6 +2130,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))] @@ -1676,15 +2148,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, @@ -1706,6 +2171,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 { @@ -1724,7 +2282,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))] @@ -2149,6 +2706,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))] @@ -2215,6 +2774,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))] @@ -2224,6 +2785,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))] @@ -2261,6 +2824,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))] @@ -2270,6 +2835,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))] @@ -2501,11 +3068,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)] @@ -2519,11 +3081,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, @@ -2547,24 +3104,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, @@ -2577,7 +3121,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)] @@ -2612,8 +3158,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 704a5d444..3b869ca74 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -26,6 +26,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"; @@ -33,14 +35,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"; @@ -197,9 +191,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, @@ -207,14 +215,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)] @@ -235,67 +261,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, @@ -347,13 +686,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, @@ -575,11 +925,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, @@ -658,27 +1003,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 { @@ -1323,13 +1767,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, } } @@ -1606,27 +2049,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(), @@ -1636,16 +2079,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 { @@ -1658,21 +2107,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)); @@ -1710,11 +2166,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, @@ -1722,11 +2177,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 { @@ -1786,11 +2242,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, @@ -1798,11 +2253,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 { @@ -1953,4 +2409,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 391474c2b..2dfdde2c8 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 cc73d695d..b83faf90e 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 + } } } @@ -167,7 +192,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, } } } @@ -556,15 +580,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, @@ -731,7 +748,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 @@ -743,6 +763,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< @@ -753,7 +803,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 @@ -1060,7 +1113,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 @@ -1208,7 +1268,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 @@ -1224,7 +1290,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 { @@ -1253,11 +1325,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(|| { @@ -1273,7 +1347,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 { @@ -1378,11 +1458,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, } } @@ -1398,15 +1473,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 @@ -1431,11 +1501,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, @@ -1445,19 +1510,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, } } @@ -1467,7 +1532,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, @@ -1475,7 +1540,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), } } @@ -1632,6 +1699,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), } } @@ -1953,37 +2023,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 { @@ -1997,37 +2360,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 { @@ -2201,3 +2533,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 25644441d..0df439715 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; @@ -505,7 +507,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; @@ -619,6 +624,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; @@ -704,6 +711,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; @@ -714,15 +723,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; @@ -775,12 +800,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; @@ -918,6 +945,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; @@ -925,6 +953,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; @@ -1385,7 +1414,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; @@ -1499,6 +1531,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::*; @@ -1584,6 +1618,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; @@ -1594,15 +1630,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; @@ -1655,12 +1707,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; @@ -1936,6 +1990,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: @@ -2192,6 +2247,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)?, ), @@ -2639,6 +2697,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", @@ -2997,6 +3061,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)?), @@ -3286,6 +3353,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)?), @@ -3461,6 +3531,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: @@ -3799,6 +3870,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, @@ -4675,6 +4751,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); @@ -4769,6 +4846,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 ad94b2707..ad3437f63 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 @@ -39,6 +39,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { RechargeRefundRecovery, LlmRouterConsume, + + MembershipUpgradeGrant, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { 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 2ee01af29..7fbd28d93 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 39b91746a..6b5cc12fe 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![ @@ -3235,6 +3817,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, } } @@ -3565,24 +4161,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}泥点"), @@ -3593,6 +4191,7 @@ mod tests { duration_days: 0, badge_label: "首充双倍".to_string(), description: format!("首充送{points_amount}泥点"), + // 已退役列:不参与运行态,写固定默认值即可。 tier: RuntimeProfileMembershipTier::Normal, enabled: true, sort_order: 0, @@ -3600,34 +4199,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] @@ -3888,173 +4511,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] @@ -4622,67 +5274,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) @@ -4741,7 +5428,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, @@ -4774,19 +5461,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() @@ -4795,8 +5477,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); @@ -4909,9 +5592,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, @@ -6824,18 +7539,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, @@ -6843,24 +7566,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, @@ -7020,13 +7779,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; @@ -7035,7 +7794,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( @@ -7044,7 +7808,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, @@ -7097,7 +7861,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, @@ -7111,7 +7875,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 @@ -7172,20 +7936,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 { @@ -7207,7 +7976,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, @@ -7216,7 +7985,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, @@ -7232,7 +8001,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() @@ -7586,22 +8358,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 { @@ -7609,8 +8377,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, @@ -7810,10 +8577,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 @@ -7838,10 +8605,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 @@ -7870,15 +8637,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, @@ -7908,11 +8668,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 @@ -7925,16 +8687,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, @@ -8416,6 +9366,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); @@ -8425,7 +9426,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 @@ -8440,40 +9440,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; } @@ -8487,39 +9486,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) { @@ -8534,9 +9523,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 @@ -8602,10 +9589,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)] @@ -8638,202 +9780,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) { @@ -8841,44 +9997,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, + ), }) } @@ -9146,8 +10411,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() @@ -9161,11 +10430,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, @@ -9180,72 +10450,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, @@ -9254,27 +10472,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()), @@ -9286,13 +10525,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()), @@ -9304,12 +10546,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, @@ -9317,116 +10561,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()), + }, }) } @@ -10797,7 +12285,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()); } @@ -10818,7 +12306,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) @@ -10943,11 +12434,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(), @@ -10970,11 +12456,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 d2050a738..e16e676b2 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; @@ -1005,9 +1008,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 2875aa7af..e2d92f761 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',