diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 55e87cce5..f6249f754 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -11,6 +11,7 @@ import { updateAdminAccount, uploadAdminEditorShowcaseCampaignImage, upsertAdminFeatureGateConfig, + upsertProfileWalletConfig, } from './adminApiClient'; afterEach(() => { @@ -71,6 +72,33 @@ test('后台账号创建和更新同时携带 Tab 与独立操作权限', async ); }); +test('账号配置一次提交初始和每日免费泥点', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({configId: 'profile_wallet'}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await upsertProfileWalletConfig('owner-token', { + initialMudPoints: 100, + dailyFreePointsPerDay: 35, + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/profile/wallet-config', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({Authorization: 'Bearer owner-token'}), + body: JSON.stringify({ + initialMudPoints: 100, + dailyFreePointsPerDay: 35, + }), + }), + ); +}); + test('灰度配置读写只使用通用 feature-gates 管理接口', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 88933171a..43030fd35 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -560,6 +560,7 @@ export interface AdminUpsertProfileRechargeProductRequest { export interface AdminUpsertProfileWalletConfigRequest { initialMudPoints: number; + dailyFreePointsPerDay: number; } export interface ProfileRedeemCodeAdminResponse { @@ -661,6 +662,7 @@ export interface ProfileRechargeProductConfigAdminListResponse { export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; + dailyFreePointsPerDay: number; createdBy: string; createdByDisplayName: string; createdAt: string; diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx new file mode 100644 index 000000000..5a2d0ff6d --- /dev/null +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.test.tsx @@ -0,0 +1,58 @@ +/* @vitest-environment jsdom */ + +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import {getProfileWalletConfig, upsertProfileWalletConfig} from '../api/adminApiClient'; +import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes'; +import {AdminProfileWalletConfigPage} from './AdminProfileWalletConfigPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败'), + getProfileWalletConfig: vi.fn(), + isAdminApiError: vi.fn(() => false), + upsertProfileWalletConfig: vi.fn(), +})); + +const configResponse: ProfileWalletConfigAdminResponse = { + configId: 'profile_wallet', initialMudPoints: 100, dailyFreePointsPerDay: 20, + createdBy: 'owner-1', createdByDisplayName: '管理员', + createdAt: '2026-07-31T01:00:00Z', updatedBy: 'owner-1', + updatedByDisplayName: '管理员', updatedAt: '2026-07-31T01:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getProfileWalletConfig).mockResolvedValue(configResponse); + vi.mocked(upsertProfileWalletConfig).mockResolvedValue({...configResponse, initialMudPoints: 120, dailyFreePointsPerDay: 35}); +}); + +test('账号配置页加载并展示每日免费泥点', async () => { + render(); + expect((await screen.findByLabelText('每日免费泥点数') as HTMLInputElement).value).toBe('20'); + expect(getProfileWalletConfig).toHaveBeenCalledWith('admin-token'); + expect(screen.getByText('每日免费泥点')).toBeTruthy(); +}); + +test('账号配置页一次保存初始和每日免费泥点', async () => { + const user = userEvent.setup(); + const onResultChange = vi.fn(); + render(); + await screen.findByLabelText('每日免费泥点数'); + fireEvent.change(screen.getByLabelText('账号初始泥点数'), {target: {value: '120'}}); + fireEvent.change(screen.getByLabelText('每日免费泥点数'), {target: {value: '35'}}); + await user.click(screen.getByRole('button', {name: '保存'})); + expect(screen.getByText('初始 120 泥点,每日免费 35 泥点')).toBeTruthy(); + await user.click(screen.getByRole('button', {name: '确认'})); + await waitFor(() => expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {initialMudPoints: 120, dailyFreePointsPerDay: 35})); + expect(onResultChange).toHaveBeenLastCalledWith(expect.objectContaining({initialMudPoints: 120, dailyFreePointsPerDay: 35})); +}); + +test('账号配置页拒绝非正整数每日免费额度', async () => { + render(); + const input = await screen.findByLabelText('每日免费泥点数'); + fireEvent.change(input, {target: {value: '1.5'}}); + expect((screen.getByRole('button', {name: '保存'}) as HTMLButtonElement).disabled).toBe(true); + expect(upsertProfileWalletConfig).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx index 83652b640..7e67f3b31 100644 --- a/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx @@ -23,6 +23,7 @@ export function AdminProfileWalletConfigPage({ onResultChange, }: AdminProfileWalletConfigPageProps) { const [initialMudPoints, setInitialMudPoints] = useState('100'); + const [dailyFreePointsPerDay, setDailyFreePointsPerDay] = useState('20'); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [loadErrorMessage, setLoadErrorMessage] = useState(''); @@ -41,6 +42,7 @@ export function AdminProfileWalletConfigPage({ const response = await getProfileWalletConfig(token); onResultChange(response); setInitialMudPoints(String(response.initialMudPoints)); + setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay)); } catch (error: unknown) { handlePageError(error, onUnauthorized, setLoadErrorMessage); } finally { @@ -53,6 +55,13 @@ export function AdminProfileWalletConfigPage({ if (isSaving) { return; } + const normalizedDailyFreePointsPerDay = parsePositiveInteger( + dailyFreePointsPerDay, + ); + if (!normalizedDailyFreePointsPerDay) { + setErrorMessage('每日免费泥点数必须是大于 0 的整数'); + return; + } const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints); if (!normalizedInitialMudPoints) { @@ -63,7 +72,7 @@ export function AdminProfileWalletConfigPage({ setErrorMessage(''); const confirmed = await confirmWrite({ action: '保存账号配置', - target: `${normalizedInitialMudPoints}泥点`, + target: `初始 ${normalizedInitialMudPoints} 泥点,每日免费 ${normalizedDailyFreePointsPerDay} 泥点`, }); if (!confirmed) { return; @@ -73,9 +82,11 @@ export function AdminProfileWalletConfigPage({ try { const response = await upsertProfileWalletConfig(token, { initialMudPoints: normalizedInitialMudPoints, + dailyFreePointsPerDay: normalizedDailyFreePointsPerDay, }); onResultChange(response); setInitialMudPoints(String(response.initialMudPoints)); + setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay)); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -120,6 +131,17 @@ export function AdminProfileWalletConfigPage({ /> + + {errorMessage ? (
{errorMessage} @@ -128,7 +150,11 @@ export function AdminProfileWalletConfigPage({
+
+
每日免费泥点
+
{result.dailyFreePointsPerDay}
+
更新人
{result.updatedByDisplayName || '-'}
@@ -169,6 +199,6 @@ export function AdminProfileWalletConfigPage({ } function parsePositiveInteger(value: string) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dd4e9ae76..7e6c9fb50 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,17 @@ --- +## 2026-07-31 每日免费泥点基础额度纳入后台钱包配置 + +- 背景:每日免费泥点已是独立余额桶,但基础发放量仍在运行时固定为 `20`,后台“账号配置”只能维护注册初始泥点,运营调整需要改代码。 +- 决策:在 `profile_wallet_config` 尾部追加带默认值 `20` 的 `daily_free_points_per_day`,与 `initial_mud_points` 共用 `/admin/api/profile/wallet-config` 和后台账号配置页一次读写。尚未初始化当日额度的用户立即使用最新值;已初始化用户的当日余额不追补、不回收,下一北京时间业务日首次触达时按最新配置重置。跨日退款可继续使当日 `granted_points` 高于基础额度,因此充值中心 `dailyFreeResetPoints` 必须显式投影配置值,不用当日已发放总额反推。 +- 迁移与边界:旧 SpacetimeDB 表行和旧迁移 JSON 均缺少新字段,自动迁移与 `migration.rs` 导入归一统一补 `20`;新字段只允许正整数。每日任务奖励、扣费桶顺序、退款归因和北京时间日切边界不变。 +- 影响范围:`module-runtime`、`spacetime-module`、`spacetime-client`、`shared-contracts`、`api-server`、`apps/admin-web`、SpacetimeDB 迁移与生成绑定。 +- 验证方式:后台页面与 API 定向测试、每日免费日切与迁移定向 Rust 测试、`npm run spacetime:generate -- --rust-only`、`npm run check:spacetime-schema`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +--- + ## 2026-07-31 发布前延期冷备份由独立 systemd 上传并补偿扫描 - 背景:Jenkins Stdb Publish 的 async 备份先生成 `uploadStatus=deferred` 的本地 tar.gz,再从 EXIT trap 用 `nohup` 启动上传。后台进程仍继承 Jenkins Cookie,作业结束时可被清理;旧 deferred manifest 也没有后续补偿扫描,导致 dev 的本地冷备份持续占满根盘。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 047fb2681..f289e180d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -235,9 +235,9 @@ npm run check:server-rs-ddd ## 用户钱包与编辑器生成扣费契约 -1. 新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `profile_wallet_config.initial_mud_points`;后台通过 `/admin/api/profile/wallet-config` 维护“账号初始泥点数”。未写入配置时默认仍为 `100` 泥点。流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。 -2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费必须按“每日免费泥点 -> 会员周期限时泥点 -> 普通永久泥点”的顺序消耗,前端不得自行决定扣费桶。扣费流水 `metadata_json` 必须记录 `dailyFreePointsDelta`、`dailyFreeDayKey`、`membershipPeriodPointsDelta`、`permanentPointsDelta` 和会员限时泥点所属 `cycleResetsAtMicros`;资产退款中的会员限时泥点只在原周期仍有效时恢复会员额度,其余会员部分进入普通永久泥点。原每日免费消费部分在同一业务日退款时恢复原当日额度;跨北京时间业务日退款时叠加到退款当日每日免费桶,不进入普通永久泥点,当日 `granted_points` 与 `remaining_points` 均可因此超过 `20`。原永久泥点消费部分无论是否跨业务日,均按退款流水中的 `permanentPointsDelta` 退回普通永久泥点。 -3. 每日免费泥点是独立于每日任务和会员周期的正式余额额度,基础发放量固定为 `20`,不得由前端或后台任务配置改写。`profile_daily_free_points` 保存当前北京时间业务日、当日基础发放及跨日退款叠加后的总额度和剩余额度;北京时间每日 `00:00` 作为业务日边界,个人中心、充值中心、账单读取和钱包扣费入口在首次触达新业务日时原子清除昨日剩余及退款叠加量,并把今日 `granted_points`、`remaining_points` 重置为 `20`。首次初始化使用 `daily_free_grant` 流水,跨日重置使用 `daily_free_reset` 流水。惰性落库不能改变“北京时间 00:00 后读取即为新日额度”的对外语义。 +1. `profile_wallet_config` 是账号初始泥点和每日免费泥点基础发放量的统一真相源;后台通过 `/admin/api/profile/wallet-config` 一次读写 `initialMudPoints` 和 `dailyFreePointsPerDay`。新用户账号完成注册并成功同步正式认证表后,注册赠送金额读取 `initial_mud_points`;未写入配置时默认为 `100`。每日免费基础发放量未写入时默认为 `20`。注册赠送流水原因仍使用 `new_user_registration_reward`,流水 ID 继续保持幂等,重复发放请求不得叠加余额。 +2. 用户钱包余额对外仍暴露为一个总余额,但后端扣费必须按“每日免费泥点 -> 会员周期限时泥点 -> 普通永久泥点”的顺序消耗,前端不得自行决定扣费桶。扣费流水 `metadata_json` 必须记录 `dailyFreePointsDelta`、`dailyFreeDayKey`、`membershipPeriodPointsDelta`、`permanentPointsDelta` 和会员限时泥点所属 `cycleResetsAtMicros`;资产退款中的会员限时泥点只在原周期仍有效时恢复会员额度,其余会员部分进入普通永久泥点。原每日免费消费部分在同一业务日退款时恢复原当日额度;跨北京时间业务日退款时叠加到退款当日每日免费桶,不进入普通永久泥点,当日 `granted_points` 与 `remaining_points` 均可因此超过当前基础发放量。原永久泥点消费部分无论是否跨业务日,均按退款流水中的 `permanentPointsDelta` 退回普通永久泥点。 +3. 每日免费泥点是独立于每日任务和会员周期的正式余额额度,基础发放量读取 `profile_wallet_config.daily_free_points_per_day`,不得由前端或后台任务配置改写。`profile_daily_free_points` 保存当前北京时间业务日、当日基础发放及跨日退款叠加后的总额度和剩余额度;北京时间每日 `00:00` 作为业务日边界,个人中心、充值中心、账单读取和钱包扣费入口在首次触达新业务日时原子清除昨日剩余及退款叠加量,并按当时最新配置重置今日 `granted_points`、`remaining_points`。同一业务日已初始化的用户不因后台改配置被即时追补或回收;新配置从尚未初始化当日额度的用户或下一次跨日重置起生效。首次初始化使用 `daily_free_grant` 流水,跨日重置使用 `daily_free_reset` 流水。充值中心的 `dailyFreeResetPoints` 显式来自该配置,不得用可因跨日退款增大的当日 `granted_points` 反推。惰性落库不能改变“北京时间 00:00 后读取即为新日额度”的对外语义。 4. 每日任务奖励继续使用 `daily_task_reward` 流水并进入普通永久泥点,但主站隐藏每日任务卡片和任务中心入口,不再把每日登录任务描述为“每日免费泥点”。任务配置、进度、领取记录和后台管理能力暂时保留,除非后续需求明确删除。 5. 编辑器画板所有会调用外部生成 provider 的入口都不从前端请求接收 `priceMudPoints`;同步请求以 SpacetimeDB `editor_generation_pricing_config` 当前全局配置计算,外部生成队列则以 `external_generation_job.price_mud_points` 保存的入队价格为准,worker 的扣费、退款、响应和资产成本不得按执行时配置重算。前端按钮泥点只作为展示。 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。 @@ -817,7 +817,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ProfileDailyFreePoints` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量固定为 `20`,`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过 `20`,下一业务日首次触达时旧余额与叠加量一并失效并重置为 `20`。 +- 作用:每日免费泥点事实源。`day_key` 使用北京时间业务日,基础发放量读取 `profile_wallet_config.daily_free_points_per_day`(未配置时默认 `20`),`remaining_points` 保存当日剩余额度;跨业务日退款的每日免费消费部分会叠加到退款当日,使 `granted_points` 和 `remaining_points` 可暂时超过当前基础发放量,下一业务日首次触达时旧余额与叠加量一并失效并按当时最新配置重置。 ### `profile_feedback_submission` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 513500932..b5d95a433 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -767,6 +767,8 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms - `profile_wallet_ledger` - `profile_wallet_config` +后台“账号配置”通过 `GET/POST /admin/api/profile/wallet-config` 一次读写账号初始泥点数和每日免费泥点数。修改每日免费额度不重算已初始化的当日余额;尚未初始化的当日额度或下一次北京时间跨日重置使用最新配置。 + 个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure,由数据库事务时间生成当日幂等事件并推进任务进度;不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 外部 API 失败审计复用 `tracking_event`,不新增表。普通 API / external-generation 调用的失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,仍回退同步直写 SpacetimeDB。BgFilter worker 是受限资源例外:provider 失败审计在 spawn 前受进程级 `1024` 硬上限保护,获准任务写入 `GENARRATIVE_TRACKING_OUTBOX_DIR/bgfilter-worker/` 独立目录;任务满载、outbox 缺失、达到保护阈值或写盘失败时直接丢弃并记录指标,不同步直写。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询: diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 58bbdcfe8..290e5b12e 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -9,7 +9,7 @@ use axum::{ }; use hmac::{Hmac, Mac}; use module_runtime::{ - AnalyticsGranularity, PROFILE_DAILY_FREE_POINTS_PER_DAY, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, + AnalyticsGranularity, 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, @@ -1028,6 +1028,7 @@ pub async fn admin_upsert_profile_wallet_config( admin.session().subject.clone(), payload.initial_mud_points, updated_at_micros as i64, + payload.daily_free_points_per_day, ) .await .map_err(|error| { @@ -1893,7 +1894,7 @@ fn build_profile_mud_point_balance_response( limited_points, limited_expires_at, daily_free_points, - daily_free_reset_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + daily_free_reset_points: record.daily_free_points.reset_points, daily_free_resets_at: record.daily_free_points.resets_at.clone(), } } @@ -2194,6 +2195,7 @@ fn build_profile_wallet_config_admin_response( updated_by: record.updated_by, updated_by_display_name, updated_at: record.updated_at, + daily_free_points_per_day: record.daily_free_points_per_day, } } @@ -2506,6 +2508,7 @@ mod tests { resets_at_micros: 1_783_872_000_000_000, updated_at: "2026-07-12T08:00:00Z".to_string(), updated_at_micros: 1_783_843_200_000_000, + reset_points: 35, } } @@ -2546,7 +2549,7 @@ mod tests { assert_eq!(balance.permanent_points, 100); assert_eq!(balance.limited_points, 80); assert_eq!(balance.daily_free_points, 20); - assert_eq!(balance.daily_free_reset_points, 20); + assert_eq!(balance.daily_free_reset_points, 35); assert_eq!( balance.limited_expires_at.as_deref(), Some("2026-07-15T00:00:00Z"), diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 0439510b1..e956bf723 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -991,6 +991,7 @@ pub fn build_runtime_profile_daily_free_points_record( resets_at_micros: snapshot.resets_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, + reset_points: snapshot.reset_points, } } @@ -1028,6 +1029,7 @@ pub fn build_runtime_profile_wallet_config_record( updated_by: snapshot.updated_by, updated_at: format_optional_audit_time(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, + daily_free_points_per_day: snapshot.daily_free_points_per_day, } } diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 38fb61831..d282cb10e 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -93,15 +93,20 @@ pub fn build_runtime_profile_wallet_config_admin_upsert_input( admin_user_id: String, initial_mud_points: u64, updated_at_micros: i64, + daily_free_points_per_day: u64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; 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); + } Ok(RuntimeProfileWalletConfigAdminUpsertInput { admin_user_id, initial_mud_points, updated_at_micros, + daily_free_points_per_day, }) } diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 313e69cb5..d4334fc1f 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -34,7 +34,7 @@ pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login"; pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录"; pub const PROFILE_TASK_DEFAULT_REWARD_POINTS: u64 = 10; pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1; -pub const PROFILE_DAILY_FREE_POINTS_PER_DAY: u64 = 20; +pub const PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY: u64 = 20; #[cfg(any())] pub const SAVE_SNAPSHOT_VERSION: u32 = 2; #[cfg(any())] @@ -742,6 +742,7 @@ pub struct RuntimeProfileDailyFreePointsSnapshot { pub remaining_points: u64, pub resets_at_micros: i64, pub updated_at_micros: i64, + pub reset_points: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -767,6 +768,7 @@ pub struct RuntimeProfileWalletConfigSnapshot { pub created_at_micros: i64, pub updated_by: String, pub updated_at_micros: i64, + pub daily_free_points_per_day: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -781,6 +783,7 @@ pub struct RuntimeProfileWalletConfigAdminUpsertInput { pub admin_user_id: String, pub initial_mud_points: u64, pub updated_at_micros: i64, + pub daily_free_points_per_day: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2410,6 +2413,7 @@ pub struct RuntimeProfileDailyFreePointsRecord { pub resets_at_micros: i64, pub updated_at: String, pub updated_at_micros: i64, + pub reset_points: u64, } #[derive(Clone, Debug, PartialEq)] @@ -2506,6 +2510,7 @@ pub struct RuntimeProfileWalletConfigRecord { pub updated_by: String, pub updated_at: String, pub updated_at_micros: i64, + pub daily_free_points_per_day: u64, } #[derive(Clone, Debug, PartialEq)] diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index 169624297..74e1924ed 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -51,6 +51,7 @@ pub enum RuntimeProfileFieldError { InvalidWalletAmount, InvalidWalletMetadata, InvalidInitialWalletPoints, + InvalidDailyFreePointsPerDay, WalletAmountOverflow, WalletBalanceOverflow, InsufficientWalletBalance, @@ -134,6 +135,9 @@ 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::WalletAmountOverflow => f.write_str("profile.wallet_amount 超出上限"), Self::WalletBalanceOverflow => f.write_str("profile.wallet_balance 超出上限"), Self::InsufficientWalletBalance => f.write_str("泥点余额不足"), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 2c376321b..5dd4a67fc 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -843,14 +843,41 @@ mod tests { updated_at_micros: Some(1_713_680_000_000_000), daily_free_points: RuntimeProfileDailyFreePointsSnapshot { day_key: 19_834, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + granted_points: PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY, remaining_points: 12, resets_at_micros: 1_713_715_200_000_000, updated_at_micros: 1_713_680_000_000_000, + reset_points: PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY, }, }); assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string())); + assert_eq!(record.daily_free_points.reset_points, 20); + } + + #[test] + fn profile_wallet_config_requires_positive_daily_free_points() { + assert_eq!( + build_runtime_profile_wallet_config_admin_upsert_input( + "admin-1".to_string(), + 100, + 1_713_680_000_000_000, + 0, + ) + .expect_err("zero daily free points should fail"), + RuntimeProfileFieldError::InvalidDailyFreePointsPerDay, + ); + assert_eq!( + build_runtime_profile_wallet_config_admin_upsert_input( + "admin-1".to_string(), + 100, + 1_713_680_000_000_000, + 35, + ) + .expect("positive daily free points should pass") + .daily_free_points_per_day, + 35, + ); } #[test] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 7375b40c8..8172e3e97 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -602,6 +602,7 @@ pub struct ProfileWalletConfigAdminResponse { pub updated_by: String, pub updated_by_display_name: String, pub updated_at: String, + pub daily_free_points_per_day: u64, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -679,6 +680,7 @@ pub struct AdminUpsertProfileRechargeProductRequest { #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileWalletConfigRequest { pub initial_mud_points: u64, + pub daily_free_points_per_day: u64, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1258,6 +1260,31 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn profile_wallet_config_uses_camel_case_daily_free_points() { + let request: AdminUpsertProfileWalletConfigRequest = serde_json::from_value(json!({ + "initialMudPoints": 100, + "dailyFreePointsPerDay": 35 + })) + .expect("wallet config request should deserialize"); + assert_eq!(request.daily_free_points_per_day, 35); + + let payload = serde_json::to_value(ProfileWalletConfigAdminResponse { + config_id: "profile_wallet".to_string(), + initial_mud_points: 100, + created_by: "owner-1".to_string(), + created_by_display_name: "管理员".to_string(), + created_at: "2026-07-31T00:00:00Z".to_string(), + updated_by: "owner-1".to_string(), + updated_by_display_name: "管理员".to_string(), + updated_at: "2026-07-31T00:00:00Z".to_string(), + daily_free_points_per_day: 35, + }) + .expect("wallet config response should serialize"); + + assert_eq!(payload["dailyFreePointsPerDay"], json!(35)); + } + #[test] fn redeem_code_admin_contract_uses_optional_camel_case_validity_window() { let request: AdminUpsertProfileRedeemCodeRequest = serde_json::from_value(json!({ 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 56b65090a..1077b5b0c 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 @@ -98,6 +98,7 @@ impl From admin_user_id: input.admin_user_id, initial_mud_points: input.initial_mud_points, updated_at_micros: input.updated_at_micros, + daily_free_points_per_day: input.daily_free_points_per_day, } } } @@ -1256,6 +1257,7 @@ pub(crate) fn map_runtime_profile_daily_free_points_snapshot( remaining_points: snapshot.remaining_points, resets_at_micros: snapshot.resets_at_micros, updated_at_micros: snapshot.updated_at_micros, + reset_points: snapshot.reset_points, } } @@ -1309,6 +1311,7 @@ pub(crate) fn map_runtime_profile_wallet_config_snapshot( created_at_micros: snapshot.created_at_micros, updated_by: snapshot.updated_by, updated_at_micros: snapshot.updated_at_micros, + daily_free_points_per_day: snapshot.daily_free_points_per_day, } } diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index df68b004f..f11c2cb89 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -1522,11 +1522,13 @@ impl SpacetimeClient { admin_user_id: String, initial_mud_points: u64, updated_at_micros: i64, + daily_free_points_per_day: u64, ) -> Result { let procedure_input = build_runtime_profile_wallet_config_admin_upsert_input( admin_user_id, initial_mud_points, updated_at_micros, + daily_free_points_per_day, ) .map_err(SpacetimeClientError::validation_failed)? .into(); diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_config_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_config_type.rs index 39a293d5e..876551512 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_config_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_config_type.rs @@ -13,6 +13,7 @@ pub struct ProfileWalletConfig { pub created_at: __sdk::Timestamp, pub updated_by: String, pub updated_at: __sdk::Timestamp, + pub daily_free_points_per_day: u64, } impl __sdk::InModule for ProfileWalletConfig { @@ -29,6 +30,7 @@ pub struct ProfileWalletConfigCols { pub created_at: __sdk::__query_builder::Col, pub updated_by: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub daily_free_points_per_day: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for ProfileWalletConfig { @@ -41,6 +43,10 @@ impl __sdk::__query_builder::HasCols for ProfileWalletConfig { 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"), + daily_free_points_per_day: __sdk::__query_builder::Col::new( + table_name, + "daily_free_points_per_day", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs index 7a4178733..318f2e040 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_daily_free_points_snapshot_type.rs @@ -12,6 +12,7 @@ pub struct RuntimeProfileDailyFreePointsSnapshot { pub remaining_points: u64, pub resets_at_micros: i64, pub updated_at_micros: i64, + pub reset_points: u64, } impl __sdk::InModule for RuntimeProfileDailyFreePointsSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_admin_upsert_input_type.rs index 2b967e875..f75640222 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_admin_upsert_input_type.rs @@ -10,6 +10,7 @@ pub struct RuntimeProfileWalletConfigAdminUpsertInput { pub admin_user_id: String, pub initial_mud_points: u64, pub updated_at_micros: i64, + pub daily_free_points_per_day: u64, } impl __sdk::InModule for RuntimeProfileWalletConfigAdminUpsertInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_snapshot_type.rs index c68000f8f..a4a0a1ea0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_config_snapshot_type.rs @@ -13,6 +13,7 @@ pub struct RuntimeProfileWalletConfigSnapshot { pub created_at_micros: i64, pub updated_by: String, pub updated_at_micros: i64, + pub daily_free_points_per_day: u64, } impl __sdk::InModule for RuntimeProfileWalletConfigSnapshot { diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 7cd9f2c16..1339ce33c 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1255,6 +1255,14 @@ where fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value { let mut next_value = value.clone(); + if table_name == "profile_wallet_config" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:旧迁移包没有每日免费额度字段,导入时保持原有每日 20 泥点语义。 + object + .entry("daily_free_points_per_day".to_string()) + .or_insert_with(|| serde_json::Value::from(20)); + } + } if table_name == "creation_entry_config" { if let Some(object) = next_value.as_object_mut() { // 中文注释:入口活动横幅字段晚于创作入口配置表加入,旧迁移包按运行态默认横幅兼容。 @@ -1689,6 +1697,19 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["expires_at"], serde_json::Value::Null); } + #[test] + fn old_profile_wallet_config_rows_default_to_twenty_daily_free_points() { + let normalized = normalize_migration_row( + "profile_wallet_config", + &serde_json::json!({ "config_id": "profile_wallet" }), + ); + + assert_eq!( + normalized["daily_free_points_per_day"], + serde_json::json!(20) + ); + } + #[test] fn old_external_generation_summary_rows_default_to_no_warning() { let normalized = normalize_migration_row( 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 5e79f56b4..56a43c81b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -92,6 +92,8 @@ pub struct ProfileWalletConfig { pub(crate) created_at: Timestamp, pub(crate) updated_by: String, pub(crate) updated_at: Timestamp, + #[default(PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY)] + pub(crate) daily_free_points_per_day: u64, } #[spacetimedb::table( @@ -3446,7 +3448,7 @@ mod tests { }), ); assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282), + resolve_daily_free_refresh_plan(Some(20_281), 60, 20_282, 20), Some(DailyFreeRefreshPlan { expired_points: 60, granted_points: 20, @@ -3464,7 +3466,7 @@ mod tests { 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!(PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY, 20); assert_eq!( runtime_profile_beijing_day_key(resets_at_micros.saturating_sub(1)), day_key, @@ -3478,27 +3480,27 @@ mod tests { #[test] fn daily_free_refresh_plan_grants_once_and_replaces_cross_day_remainder() { assert_eq!( - resolve_daily_free_refresh_plan(None, 0, 20_280), + resolve_daily_free_refresh_plan(None, 0, 20_280, 35), Some(DailyFreeRefreshPlan { expired_points: 0, - granted_points: 20, + granted_points: 35, reset: false, }), ); assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280), + resolve_daily_free_refresh_plan(Some(20_280), 7, 20_280, 35), None, ); assert_eq!( - resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281), + resolve_daily_free_refresh_plan(Some(20_280), 7, 20_281, 35), Some(DailyFreeRefreshPlan { expired_points: 7, - granted_points: 20, + granted_points: 35, reset: true, }), ); assert_eq!( - resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280), + resolve_daily_free_refresh_plan(Some(20_281), 7, 20_280, 35), None, ); } @@ -7169,6 +7171,7 @@ fn build_profile_wallet_config_snapshot( created_at_micros: 0, updated_by: String::new(), updated_at_micros: 0, + daily_free_points_per_day: PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY, }) } @@ -7399,6 +7402,7 @@ fn upsert_profile_wallet_config_record( input.admin_user_id, input.initial_mud_points, input.updated_at_micros, + input.daily_free_points_per_day, ) .map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros); @@ -7423,6 +7427,7 @@ fn upsert_profile_wallet_config_record( .unwrap_or(updated_at), updated_by: validated_input.admin_user_id, updated_at, + daily_free_points_per_day: validated_input.daily_free_points_per_day, }); Ok(build_profile_wallet_config_snapshot_from_row(&inserted)) } @@ -8637,17 +8642,18 @@ fn resolve_daily_free_refresh_plan( current_day_key: Option, current_remaining_points: u64, day_key: i64, + daily_free_points_per_day: u64, ) -> 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, + granted_points: daily_free_points_per_day, reset: true, }), None => Some(DailyFreeRefreshPlan { expired_points: 0, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + granted_points: daily_free_points_per_day, reset: false, }), } @@ -8655,6 +8661,8 @@ fn resolve_daily_free_refresh_plan( 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 daily_free_points_per_day = + build_profile_wallet_config_snapshot(ctx).daily_free_points_per_day; let current = ctx .db .profile_daily_free_points() @@ -8667,6 +8675,7 @@ fn refresh_profile_daily_free_points(ctx: &ReducerContext, user_id: &str, now: T .map(|row| row.remaining_points) .unwrap_or(0), day_key, + daily_free_points_per_day, ) else { return; }; @@ -8738,6 +8747,7 @@ fn build_profile_daily_free_points_snapshot( now: Timestamp, ) -> RuntimeProfileDailyFreePointsSnapshot { let day_key = runtime_profile_beijing_day_key(now.to_micros_since_unix_epoch()); + let reset_points = build_profile_wallet_config_snapshot(ctx).daily_free_points_per_day; ctx.db .profile_daily_free_points() .user_id() @@ -8748,13 +8758,15 @@ fn build_profile_daily_free_points_snapshot( 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(), + reset_points, }) .unwrap_or(RuntimeProfileDailyFreePointsSnapshot { day_key, - granted_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, - remaining_points: PROFILE_DAILY_FREE_POINTS_PER_DAY, + granted_points: reset_points, + remaining_points: reset_points, resets_at_micros: profile_daily_free_points_resets_at_micros(day_key), updated_at_micros: now.to_micros_since_unix_epoch(), + reset_points, }) } @@ -10275,6 +10287,7 @@ fn build_profile_wallet_config_snapshot_from_row( 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(), + daily_free_points_per_day: row.daily_free_points_per_day, } }