feat: 支持充值商品配置和档位首充

This commit is contained in:
2026-05-15 06:11:57 +08:00
parent 9c33cc565c
commit c7fe793a9e
36 changed files with 2096 additions and 72 deletions

View File

@@ -16,6 +16,14 @@
--- ---
## 2026-05-15 充值商品配置入库且首充按档位独立计算
- 背景:泥点充值原来依赖代码中的固定商品目录,首充双倍也按账号是否买过任一泥点统一隐藏,导致用户买过 `points_60` 后其它未购买档位也失去首充展示。
- 决策:新增 `profile_recharge_product_config` 作为泥点和会员商品配置真相源,默认商品只在空库时播种;后台通过“充值商品”页维护配置。泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断,`hasPointsRecharged` 仅保留为账号是否发生过任一泥点充值的兼容字段,不再驱动所有商品展示或结算。
- 影响范围:`module-runtime` 充值领域输入、`spacetime-module` 充值表与 procedure、`spacetime-client` bindings/facade、`api-server` `/admin/api/profile/recharge-products``apps/admin-web` 充值商品页、主站充值弹窗。
- 验证方式:执行 `cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run admin-web:typecheck``npm run check:spacetime-schema`
- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 2026-05-14 创作页图像输入统一封装为图像组件 ## 2026-05-14 创作页图像输入统一封装为图像组件
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。

View File

@@ -286,6 +286,14 @@
- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。 - 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md` - 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## SpacetimeDB schema guard 新增表应校验 sidecar不应把新增本身当失败
- 现象:新增 SpacetimeDB 表后schema guard 只要看到新 table accessor 就直接报错,哪怕 `migration.rs`、表目录和生成绑定都已同步。
- 原因:守卫脚本把“发现新增表”本身当成失败,而不是把它当成需要校验 sidecar 的信号。
- 处理:新增表只应触发 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 bindings 的一致性检查;当 sidecar 都已同步时,新增表应允许通过。不要把“合法新增表”本身判成失败。
- 验证:`npm run check:spacetime-schema` 在新增表、迁移、目录和绑定都同步后应通过。
- 关联:`scripts/check-spacetime-schema-guard.mjs``server-rs/crates/spacetime-module/src/migration.rs``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature ## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
- 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform` - 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform`

View File

@@ -14,6 +14,7 @@ import type {
AdminTrackingEventListQuery, AdminTrackingEventListQuery,
AdminTrackingEventListResponse, AdminTrackingEventListResponse,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileTaskConfigRequest,
ApiErrorEnvelope, ApiErrorEnvelope,
@@ -21,6 +22,8 @@ import type {
ApiSuccessEnvelope, ApiSuccessEnvelope,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse, ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminListResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminListResponse,
@@ -279,6 +282,27 @@ export function disableProfileTaskConfig(
); );
} }
export function listProfileRechargeProducts(token: string) {
return request<ProfileRechargeProductConfigAdminListResponse>(
'/admin/api/profile/recharge-products',
{token},
);
}
export function upsertProfileRechargeProduct(
token: string,
payload: AdminUpsertProfileRechargeProductRequest,
) {
return request<ProfileRechargeProductConfigAdminResponse>(
'/admin/api/profile/recharge-products',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) { function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, ''); return value.trim().replace(/\/+$/, '');
} }

View File

@@ -132,6 +132,8 @@ export interface AdminDebugHttpResponse {
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily'; export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export interface AdminTrackingEventListQuery { export interface AdminTrackingEventListQuery {
eventKey?: string; eventKey?: string;
@@ -207,6 +209,21 @@ export interface AdminDisableProfileTaskConfigRequest {
taskId: string; taskId: string;
} }
export interface AdminUpsertProfileRechargeProductRequest {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel?: string | null;
description?: string | null;
tier: ProfileMembershipTier;
enabled: boolean;
sortOrder: number;
}
export interface ProfileRedeemCodeAdminResponse { export interface ProfileRedeemCodeAdminResponse {
code: string; code: string;
mode: ProfileRedeemCodeMode; mode: ProfileRedeemCodeMode;
@@ -260,6 +277,29 @@ export interface ProfileTaskConfigAdminListResponse {
entries: ProfileTaskConfigAdminResponse[]; entries: ProfileTaskConfigAdminResponse[];
} }
export interface ProfileRechargeProductConfigAdminResponse {
productId: string;
title: string;
priceCents: number;
kind: ProfileRechargeProductKind;
pointsAmount: number;
bonusPoints: number;
durationDays: number;
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface ProfileRechargeProductConfigAdminListResponse {
entries: ProfileRechargeProductConfigAdminResponse[];
}
export interface AdminTrackingEventEntryPayload { export interface AdminTrackingEventEntryPayload {
eventId: string; eventId: string;
eventKey: string; eventKey: string;

View File

@@ -9,6 +9,7 @@ import {
import type { import type {
AdminSessionPayload, AdminSessionPayload,
ProfileInviteCodeAdminResponse, ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminResponse, ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse, ProfileTaskConfigAdminResponse,
} from '../api/adminApiTypes'; } from '../api/adminApiTypes';
@@ -23,6 +24,7 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
@@ -47,6 +49,8 @@ export function AdminApp() {
useState<ProfileInviteCodeAdminResponse | null>(null); useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] = const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null); useState<ProfileTaskConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const clearSession = useCallback((message = '') => { const clearSession = useCallback((message = '') => {
clearStoredAdminToken(); clearStoredAdminToken();
@@ -55,6 +59,7 @@ export function AdminApp() {
setRedeemResult(null); setRedeemResult(null);
setInviteResult(null); setInviteResult(null);
setTaskConfigResult(null); setTaskConfigResult(null);
setRechargeProductResult(null);
setStatus('guest'); setStatus('guest');
setLoginNotice(message); setLoginNotice(message);
}, []); }, []);
@@ -124,6 +129,7 @@ export function AdminApp() {
setRedeemResult(null); setRedeemResult(null);
setInviteResult(null); setInviteResult(null);
setTaskConfigResult(null); setTaskConfigResult(null);
setRechargeProductResult(null);
setLoginNotice(''); setLoginNotice('');
setStatus('authenticated'); setStatus('authenticated');
}, []); }, []);
@@ -207,6 +213,14 @@ export function AdminApp() {
onResultChange={setTaskConfigResult} onResultChange={setTaskConfigResult}
/> />
) : null} ) : null}
{routeId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setRechargeProductResult}
/>
) : null}
</AdminShell> </AdminShell>
); );
} }

View File

@@ -1,5 +1,6 @@
import { import {
Bug, Bug,
BadgeDollarSign,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
ShieldCheck, ShieldCheck,
@@ -32,6 +33,7 @@ const routeIcons = {
redeem: TicketPercent, redeem: TicketPercent,
invite: TicketCheck, invite: TicketCheck,
tasks: ListChecks, tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'creation-entry': SlidersHorizontal, 'creation-entry': SlidersHorizontal,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>; } satisfies Record<AdminRouteId, typeof LayoutDashboard>;

View File

@@ -6,6 +6,7 @@ export type AdminRouteId =
| 'redeem' | 'redeem'
| 'invite' | 'invite'
| 'tasks' | 'tasks'
| 'recharge-products'
| 'creation-entry'; | 'creation-entry';
export interface AdminRouteDefinition { export interface AdminRouteDefinition {
@@ -22,6 +23,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'}, {id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'}, {id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
]; ];

View File

@@ -0,0 +1,451 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
listProfileRechargeProducts,
upsertProfileRechargeProduct,
} from '../api/adminApiClient';
import type {
ProfileMembershipTier,
ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminRechargeProductPageProps {
token: string;
result: ProfileRechargeProductConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileRechargeProductConfigAdminResponse) => void;
}
const productKinds: Array<{value: ProfileRechargeProductKind; label: string}> = [
{value: 'points', label: '泥点'},
{value: 'membership', label: '会员'},
];
const membershipTiers: Array<{value: ProfileMembershipTier; label: string}> = [
{value: 'month', label: '月卡'},
{value: 'season', label: '季卡'},
{value: 'year', label: '年卡'},
];
export function AdminRechargeProductPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRechargeProductPageProps) {
const [entries, setEntries] = useState<
ProfileRechargeProductConfigAdminResponse[]
>([]);
const [productId, setProductId] = useState('points_60');
const [title, setTitle] = useState('60泥点');
const [priceCents, setPriceCents] = useState('600');
const [kind, setKind] = useState<ProfileRechargeProductKind>('points');
const [pointsAmount, setPointsAmount] = useState('60');
const [bonusPoints, setBonusPoints] = useState('60');
const [durationDays, setDurationDays] = useState('0');
const [badgeLabel, setBadgeLabel] = useState('首充双倍');
const [description, setDescription] = useState('首充送60泥点');
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
const [enabled, setEnabled] = useState(true);
const [sortOrder, setSortOrder] = useState('0');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshProducts() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileRechargeProducts(token);
const sortedEntries = sortProducts(response.entries);
setEntries(sortedEntries);
const firstEntry = sortedEntries[0];
if (firstEntry) {
fillForm(firstEntry);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: enabled ? '保存充值商品' : '停用充值商品',
target: productId.trim(),
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileRechargeProduct(token, {
productId: productId.trim(),
title: title.trim(),
priceCents: parsePositiveInteger(priceCents),
kind,
pointsAmount: kind === 'points' ? parsePositiveInteger(pointsAmount) : 0,
bonusPoints: kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0,
durationDays:
kind === 'membership' ? parsePositiveInteger(durationDays) : 0,
badgeLabel: kind === 'points' ? badgeLabel.trim() : '',
description: description.trim(),
tier: kind === 'membership' ? tier : 'normal',
enabled,
sortOrder: parseInteger(sortOrder),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function upsertEntry(next: ProfileRechargeProductConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.productId !== next.productId);
return sortProducts([...rest, next]);
});
}
function fillForm(entry: ProfileRechargeProductConfigAdminResponse) {
setProductId(entry.productId);
setTitle(entry.title);
setPriceCents(String(entry.priceCents));
setKind(entry.kind);
setPointsAmount(String(entry.pointsAmount));
setBonusPoints(String(entry.bonusPoints));
setDurationDays(String(entry.durationDays));
setBadgeLabel(entry.badgeLabel);
setDescription(entry.description);
setTier(entry.tier);
setEnabled(entry.enabled);
setSortOrder(String(entry.sortOrder));
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshProducts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Product ID</span>
<input
value={productId}
onChange={(event) => setProductId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-segmented-control" role="tablist">
{productKinds.map((item) => (
<button
data-active={kind === item.value}
key={item.value}
type="button"
onClick={() => {
setKind(item.value);
if (item.value === 'points') {
setTier('normal');
setDurationDays('0');
} else {
setBonusPoints('0');
setPointsAmount('0');
setTier(tier === 'normal' ? 'month' : tier);
}
}}
>
{item.label}
</button>
))}
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={priceCents}
onChange={(event) => setPriceCents(event.target.value)}
/>
</label>
</div>
{kind === 'points' ? (
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={pointsAmount}
onChange={(event) => setPointsAmount(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={0}
step={1}
type="number"
value={bonusPoints}
onChange={(event) => setBonusPoints(event.target.value)}
/>
</label>
</div>
) : (
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={tier}
onChange={(event) =>
setTier(event.target.value as ProfileMembershipTier)
}
>
{membershipTiers.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={durationDays}
onChange={(event) => setDurationDays(event.target.value)}
/>
</label>
</div>
)}
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={kind !== 'points'}
value={badgeLabel}
onChange={(event) => setBadgeLabel(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span></span>
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !productId.trim() || !title.trim()}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.productId}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.productId}
</button>
<small>{entry.productId}</small>
</td>
<td>{formatProductKind(entry.kind)}</td>
<td>{formatPrice(entry.priceCents)}</td>
<td>{formatProductContent(entry)}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{result ? (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result.updatedAt}</span>
</div>
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.productId}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
</dl>
</section>
) : null}
</div>
</div>
{confirmDialog}
</section>
);
}
function sortProducts(entries: ProfileRechargeProductConfigAdminResponse[]) {
return [...entries].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.productId.localeCompare(right.productId);
});
}
function formatProductKind(kind: ProfileRechargeProductKind) {
return kind === 'points' ? '泥点' : '会员';
}
function formatTier(tier: ProfileMembershipTier) {
if (tier === 'month') {
return '月卡';
}
if (tier === 'season') {
return '季卡';
}
if (tier === 'year') {
return '年卡';
}
return '普通';
}
function formatProductContent(entry: ProfileRechargeProductConfigAdminResponse) {
if (entry.kind === 'points') {
return `${entry.pointsAmount}+${entry.bonusPoints}`;
}
return `${formatTier(entry.tier)} ${entry.durationDays}`;
}
function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`;
}
function parsePositiveInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
}
function parseNonNegativeInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
return 0;
}
return parsed;
}

View File

@@ -24,9 +24,13 @@
| `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280泥点 | | `points_1280` | 1280 | 12800 | 首充双倍 | 首充送1280泥点 |
| `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280泥点 | | `points_3280` | 3280 | 32800 | 首充双倍 | 首充送3280泥点 |
泥点充值固定为 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。全部档位参与首充双倍:用户历史上没有 `points_recharge` 流水时,本次购买到账泥点为基础泥点与等额赠送泥点之和;已有充值流水后只到账基础泥点。实际到账泥点写入交易流水,余额以 SpacetimeDB projection 为准 泥点充值默认初始化 `¥6 / ¥18 / ¥30 / ¥68 / ¥128 / ¥328` 六个档位。默认档位只作为空库种子写入 `profile_recharge_product_config`,运行时充值中心展示、下单校验和支付确认结算都以 SpacetimeDB 配置表为准,不再把代码中的商品目录作为业务真相源
充值中心返回的 `hasPointsRecharged` 是首充资格的展示与结算共同依据:当它为 `true` 时,后端下发的泥点套餐应只保留基础泥点、清空首充徽标与赠送文案;前端即使收到旧版本快照中残留的 `bonusPoints` / `badgeLabel`,也必须按 `hasPointsRecharged` 隐藏“首充双倍”和 `基础+赠送` 展示。这样可以避免第二次充值只到账基础泥点时,弹窗仍显示 `60+60` 等已失效权益 全部泥点档位参与档位首充双倍:首充资格按 `productId` 独立判断。用户购买过 `points_60` 后,再次购买 `points_60` 只到账基础泥点;但 `points_180``points_300` 等未购买过的档位仍保留各自的首充赠送。实际到账泥点写入 `profile_recharge_order.points_delta` 与钱包流水,余额以 SpacetimeDB projection 为准
充值中心返回的 `pointProducts` 已由后端按当前账号和每个 `productId` 计算有效展示状态:已完成首充的档位清空 `bonusPoints``badgeLabel` 与首充说明,未完成首充的档位继续显示 `首充双倍``基础+赠送``hasPointsRecharged` 仅保留为兼容字段,表示账号是否发生过任一泥点充值,不再作为隐藏所有档位首充或计算结算金额的依据。前端不得再用 `hasPointsRecharged` 对所有泥点商品做统一屏蔽。
后台通过“充值商品”页维护 `profile_recharge_product_config`,字段包括 `productId`、标题、商品类型、金额分、基础泥点、首充赠送泥点、会员天数、徽标、说明、会员层级、启用状态和排序。保存后新的充值中心快照、下单与支付确认立即读取配置表;历史订单继续保留下单当时写入的商品标题、金额和状态。
### 2.2 会员卡套餐 ### 2.2 会员卡套餐
@@ -157,7 +161,7 @@
1. 普通用户打开弹窗能看到泥点与会员套餐。 1. 普通用户打开弹窗能看到泥点与会员套餐。
2. 泥点购买后余额增加,流水来源为 `points_recharge` 2. 泥点购买后余额增加,流水来源为 `points_recharge`
3. 首充赠送只在首次泥点充值时生效。 3. 首充赠送按泥点档位独立生效。
4. 已产生 `points_recharge` 流水后,再打开充值弹窗不应展示“首充双倍”徽标或 `60+60` 等赠送泥点组合。 4. 某个 `productId` 已成功完成泥点充值后,再打开充值弹窗时仅该档位不再展示“首充双倍”徽标或 `60+60` 等赠送泥点组合,其他未购买过的泥点档位仍展示各自首充权益
5. 会员购买后会员状态与到期时间立即更新。 5. 会员购买后会员状态与到期时间立即更新。
6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。 6. 移动端弹窗单列可滚动,桌面端接近参考图卡片网格。

View File

@@ -24,7 +24,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| --- | --- | | --- | --- |
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` | | 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
| 认证 | `auth_store_snapshot`, `auth_store_projection_meta`, `user_account`, `auth_identity`, `refresh_session` | | 认证 | `auth_store_snapshot`, `auth_store_projection_meta`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_feedback_submission`, `profile_save_archive` | | 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_product_config`, `profile_recharge_order`, `profile_feedback_submission`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
@@ -336,11 +336,23 @@ SELECT * FROM public_work_like WHERE user_id = '<user_id>';
SELECT * FROM profile_membership WHERE user_id = '<user_id>'; SELECT * FROM profile_membership WHERE user_id = '<user_id>';
``` ```
### `profile_recharge_product_config`
- 作用:充值商品配置表,是泥点和会员充值商品的运行时真相源;默认商品只在空库时作为种子写入,后台“充值商品”页后续维护该表。
- 结构:`product_id PK: String`, `title: String`, `price_cents: u64`, `kind: RuntimeProfileRechargeProductKind`, `points_amount: u64`, `bonus_points: u64`, `duration_days: u32`, `badge_label: String`, `description: String`, `tier: RuntimeProfileMembershipTier`, `enabled: bool`, `sort_order: i32`, `created_by: String`, `created_at: Timestamp`, `updated_by: String`, `updated_at: Timestamp`
- 索引:主键 `product_id`
- 充值口径:下单只允许读取 `enabled = true` 的商品;支付确认允许按订单 `product_id` 读取已存在商品完成历史 pending 订单。泥点首充按 `user_id + product_id` 独立判断,只清空已购买档位的 `bonus_points``badge_label` 和首充说明。
```sql
SELECT * FROM profile_recharge_product_config ORDER BY sort_order;
SELECT * FROM profile_recharge_product_config WHERE product_id = '<product_id>';
```
### `profile_recharge_order` ### `profile_recharge_order`
- 作用:充值订单表,记录用户购买泥点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。 - 作用:充值订单表,记录用户购买泥点或会员的订单、支付渠道、支付时间、积分变更和会员到期时间。
- 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option<Timestamp>`, `provider_transaction_id: Option<String>`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>` - 结构:`order_id PK: String`, `user_id: String`, `product_id: String`, `product_title: String`, `kind: RuntimeProfileRechargeProductKind`, `amount_cents: u64`, `status: RuntimeProfileRechargeOrderStatus`, `payment_channel: String`, `paid_at: Option<Timestamp>`, `provider_transaction_id: Option<String>`, `created_at: Timestamp`, `points_delta: i64`, `membership_expires_at: Option<Timestamp>`
- 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid``provider_transaction_id` 保存微信支付平台订单号。 - 支付口径:`mock` 渠道创建后立即 `paid` 并入账;微信小程序 `wechat_mp` 渠道创建时为 `pending`,微信支付通知确认后改为 `paid``provider_transaction_id` 保存微信支付平台订单号。订单商品信息来自 `profile_recharge_product_config`,泥点首充赠送按同一用户的同一 `product_id` 历史 `paid` 订单独立计算。
- 索引:`user_id`, `(user_id, created_at)` - 索引:`user_id`, `(user_id, created_at)`
```sql ```sql

View File

@@ -572,9 +572,6 @@ function compareTables(baseTables, currentTables) {
for (const [accessor, table] of currentTables) { for (const [accessor, table] of currentTables) {
if (!baseTables.has(accessor)) { if (!baseTables.has(accessor)) {
schemaChanged = true; schemaChanged = true;
failures.push(
`${table.path}:${table.line}: 新增 SpacetimeDB 表 ${accessor}。请同步 migration.rs、表目录和生成绑定。`,
);
} }
} }

View File

@@ -8,8 +8,9 @@ use crate::{
}, },
runtime_profile::{ runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_list_profile_invite_codes, admin_list_profile_redeem_codes, admin_list_profile_invite_codes, admin_list_profile_recharge_products,
admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_list_profile_redeem_codes, admin_list_profile_task_configs,
admin_upsert_profile_invite_code, admin_upsert_profile_recharge_product,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
}, },
state::AppState, state::AppState,
@@ -104,7 +105,14 @@ pub fn router(state: AppState) -> Router<AppState> {
) )
.route( .route(
"/admin/api/profile/tasks/disable", "/admin/api/profile/tasks/disable",
axum::routing::post(admin_disable_profile_task_config) axum::routing::post(admin_disable_profile_task_config).route_layer(
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/profile/recharge-products",
get(admin_list_profile_recharge_products)
.post(admin_upsert_profile_recharge_product)
.route_layer(middleware::from_fn_with_state(state, require_admin_auth)), .route_layer(middleware::from_fn_with_state(state, require_admin_auth)),
) )
} }

View File

@@ -9,13 +9,15 @@ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -23,12 +25,16 @@ use shared_contracts::runtime::{
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest, ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest,
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse,
AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN,
PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, PROFILE_MEMBERSHIP_TIER_SEASON,
PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_MEMBERSHIP_TIER_YEAR, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP,
PROFILE_RECHARGE_PRODUCT_KIND_POINTS, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
@@ -42,6 +48,7 @@ use shared_contracts::runtime::{
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse, ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
@@ -669,6 +676,84 @@ pub async fn admin_disable_profile_task_config(
)) ))
} }
pub async fn admin_list_profile_recharge_products(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_recharge_products(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRechargeProductConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_recharge_product_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_recharge_product(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileRechargeProductRequest>,
) -> Result<Json<Value>, Response> {
let kind = parse_profile_recharge_product_kind(&payload.kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_recharge_product(
admin.session().subject.clone(),
payload.product_id,
payload.title,
payload.price_cents,
kind,
payload.points_amount,
payload.bonus_points,
payload.duration_days,
payload.badge_label.unwrap_or_default(),
payload.description.unwrap_or_default(),
tier,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_recharge_product_config_admin_response(record),
))
}
pub async fn admin_list_profile_redeem_codes( pub async fn admin_list_profile_redeem_codes(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -1172,6 +1257,29 @@ fn build_profile_task_config_admin_response(
} }
} }
fn build_profile_recharge_product_config_admin_response(
record: RuntimeProfileRechargeProductConfigRecord,
) -> ProfileRechargeProductConfigAdminResponse {
ProfileRechargeProductConfigAdminResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: format_profile_recharge_product_kind(record.kind).to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: format_profile_membership_tier(record.tier).to_string(),
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
}
}
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> { fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
let metadata = match metadata { let metadata = match metadata {
Some(Value::Null) | None => json!({}), Some(Value::Null) | None => json!({}),
@@ -1233,6 +1341,28 @@ fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String
} }
} }
fn parse_profile_recharge_product_kind(
raw: &str,
) -> Result<RuntimeProfileRechargeProductKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_RECHARGE_PRODUCT_KIND_POINTS => Ok(RuntimeProfileRechargeProductKind::Points),
PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP => {
Ok(RuntimeProfileRechargeProductKind::Membership)
}
_ => Err("充值商品类型无效".to_string()),
}
}
fn parse_profile_membership_tier(raw: &str) -> Result<RuntimeProfileMembershipTier, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal),
PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month),
PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season),
PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year),
_ => Err("会员档位无效".to_string()),
}
}
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> { fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site), TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
@@ -1269,6 +1399,22 @@ fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str
} }
} }
fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) -> &'static str {
match kind {
RuntimeProfileRechargeProductKind::Points => PROFILE_RECHARGE_PRODUCT_KIND_POINTS,
RuntimeProfileRechargeProductKind::Membership => PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP,
}
}
fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str {
match tier {
RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL,
RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH,
RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON,
RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR,
}
}
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str { fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
match scope_kind { match scope_kind {
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE, RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
@@ -1702,6 +1848,7 @@ mod tests {
for uri in [ for uri in [
"/admin/api/profile/redeem-codes", "/admin/api/profile/redeem-codes",
"/admin/api/profile/invite-codes", "/admin/api/profile/invite-codes",
"/admin/api/profile/recharge-products",
] { ] {
let response = app let response = app
.clone() .clone()

View File

@@ -292,6 +292,31 @@ pub fn build_runtime_profile_recharge_product_record(
} }
} }
pub fn build_runtime_profile_recharge_product_config_record(
snapshot: RuntimeProfileRechargeProductConfigSnapshot,
) -> RuntimeProfileRechargeProductConfigRecord {
RuntimeProfileRechargeProductConfigRecord {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: snapshot.kind,
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: snapshot.tier,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_membership_benefit_record( pub fn build_runtime_profile_membership_benefit_record(
snapshot: RuntimeProfileMembershipBenefitSnapshot, snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> RuntimeProfileMembershipBenefitRecord { ) -> RuntimeProfileMembershipBenefitRecord {
@@ -1114,9 +1139,9 @@ fn hash_runtime_profile_recharge_order_key(
pub fn resolve_runtime_profile_points_recharge_delta( pub fn resolve_runtime_profile_points_recharge_delta(
product: &RuntimeProfileRechargeProductSnapshot, product: &RuntimeProfileRechargeProductSnapshot,
has_points_recharged: bool, has_product_recharged: bool,
) -> u64 { ) -> u64 {
let bonus_points = if has_points_recharged { let bonus_points = if has_product_recharged {
0 0
} else { } else {
product.bonus_points product.bonus_points

View File

@@ -11,7 +11,7 @@ use shared_kernel::{
use crate::domain::*; use crate::domain::*;
use crate::errors::*; use crate::errors::*;
use crate::{format_utc_micros, runtime_profile_recharge_product_by_id}; use crate::format_utc_micros;
pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8; pub const PROFILE_USER_TAG_MAX_COUNT: usize = 8;
pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16; pub const PROFILE_USER_TAG_MAX_CHARS: usize = 16;
@@ -259,9 +259,6 @@ pub fn build_runtime_profile_recharge_order_create_input(
let user_id = normalize_runtime_profile_user_id(user_id)?; let user_id = normalize_runtime_profile_user_id(user_id)?;
let product_id = let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?; normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
if runtime_profile_recharge_product_by_id(&product_id).is_none() {
return Err(RuntimeProfileFieldError::UnknownRechargeProduct);
}
let payment_channel = normalize_required_string(payment_channel) let payment_channel = normalize_required_string(payment_channel)
.unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string()); .unwrap_or_else(|| PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK.to_string());
@@ -273,6 +270,78 @@ pub fn build_runtime_profile_recharge_order_create_input(
}) })
} }
pub fn build_runtime_profile_recharge_product_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileRechargeProductAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileRechargeProductAdminListInput { admin_user_id })
}
#[allow(clippy::too_many_arguments)]
pub fn build_runtime_profile_recharge_product_admin_upsert_input(
admin_user_id: String,
product_id: String,
title: String,
price_cents: u64,
kind: RuntimeProfileRechargeProductKind,
points_amount: u64,
bonus_points: u64,
duration_days: u32,
badge_label: String,
description: String,
tier: RuntimeProfileMembershipTier,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileRechargeProductAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let product_id =
normalize_required_string(product_id).ok_or(RuntimeProfileFieldError::MissingProductId)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingProductTitle)?;
if price_cents == 0 {
return Err(RuntimeProfileFieldError::InvalidRechargeProductPrice);
}
match kind {
RuntimeProfileRechargeProductKind::Points => {
if points_amount == 0 {
return Err(RuntimeProfileFieldError::InvalidRechargeProductPoints);
}
if duration_days != 0 || tier != RuntimeProfileMembershipTier::Normal {
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
}
}
RuntimeProfileRechargeProductKind::Membership => {
if duration_days == 0 {
return Err(RuntimeProfileFieldError::InvalidRechargeProductDuration);
}
if points_amount != 0
|| bonus_points != 0
|| tier == RuntimeProfileMembershipTier::Normal
{
return Err(RuntimeProfileFieldError::InvalidRechargeProductTier);
}
}
}
Ok(RuntimeProfileRechargeProductAdminUpsertInput {
admin_user_id,
product_id,
title,
price_cents,
kind,
points_amount,
bonus_points,
duration_days,
badge_label: normalize_optional_string(Some(badge_label)).unwrap_or_default(),
description: normalize_optional_string(Some(description)).unwrap_or_default(),
tier,
enabled,
sort_order,
updated_at_micros,
})
}
pub fn build_runtime_profile_recharge_order_paid_input( pub fn build_runtime_profile_recharge_order_paid_input(
order_id: String, order_id: String,
paid_at_micros: i64, paid_at_micros: i64,

View File

@@ -986,6 +986,27 @@ pub struct RuntimeProfileRechargeProductSnapshot {
pub tier: RuntimeProfileMembershipTier, pub tier: RuntimeProfileMembershipTier,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductConfigSnapshot {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipBenefitSnapshot { pub struct RuntimeProfileMembershipBenefitSnapshot {
@@ -1054,6 +1075,47 @@ pub struct RuntimeProfileRechargeCenterProcedureResult {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductAdminUpsertInput {
pub admin_user_id: String,
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRechargeProductConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeProductAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRechargeProductConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeCenterGetInput { pub struct RuntimeProfileRechargeCenterGetInput {
@@ -1463,6 +1525,28 @@ pub struct RuntimeProfileRechargeProductRecord {
pub tier: RuntimeProfileMembershipTier, pub tier: RuntimeProfileMembershipTier,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRechargeProductConfigRecord {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileMembershipBenefitRecord { pub struct RuntimeProfileMembershipBenefitRecord {
pub benefit_name: String, pub benefit_name: String,

View File

@@ -74,6 +74,12 @@ pub enum RuntimeProfileFieldError {
TaskAlreadyClaimed, TaskAlreadyClaimed,
MissingOrderId, MissingOrderId,
MissingProductId, MissingProductId,
MissingProductTitle,
InvalidRechargeProductPrice,
InvalidRechargeProductPoints,
InvalidRechargeProductDuration,
InvalidRechargeProductKind,
InvalidRechargeProductTier,
MissingWorldKey, MissingWorldKey,
MissingBottomTab, MissingBottomTab,
MissingCheckpointSessionId, MissingCheckpointSessionId,
@@ -136,6 +142,14 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"), Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"), Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"), Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"),
Self::InvalidRechargeProductPrice => f.write_str("recharge.price_cents 必须大于 0"),
Self::InvalidRechargeProductPoints => f.write_str("泥点商品 points_amount 必须大于 0"),
Self::InvalidRechargeProductDuration => {
f.write_str("会员商品 duration_days 必须大于 0")
}
Self::InvalidRechargeProductKind => f.write_str("充值商品类型无效"),
Self::InvalidRechargeProductTier => f.write_str("会员商品 tier 无效"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"), Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"), Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"), Self::MissingCheckpointSessionId => f.write_str("checkpoint.session_id 不能为空"),

View File

@@ -77,19 +77,11 @@ pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargePr
] ]
} }
/// 中文注释:充值中心展示当前账号本次实际可生效的首充赠送状态 /// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算
pub fn resolve_runtime_profile_recharge_point_products( pub fn resolve_runtime_profile_recharge_point_products(
has_points_recharged: bool, _has_points_recharged: bool,
) -> Vec<RuntimeProfileRechargeProductSnapshot> { ) -> Vec<RuntimeProfileRechargeProductSnapshot> {
let mut products = runtime_profile_recharge_point_products(); runtime_profile_recharge_point_products()
if has_points_recharged {
for product in &mut products {
product.bonus_points = 0;
product.badge_label.clear();
product.description = product.title.clone();
}
}
products
} }
pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot> pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot>
@@ -722,32 +714,33 @@ mod tests {
} }
#[test] #[test]
fn recharge_point_products_resolve_effective_first_bonus_display() { fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() {
let first_recharge_products = resolve_runtime_profile_recharge_point_products(false); let first_recharge_products = resolve_runtime_profile_recharge_point_products(false);
assert_eq!(first_recharge_products[0].bonus_points, 60); assert_eq!(first_recharge_products[0].bonus_points, 60);
assert_eq!(first_recharge_products[0].badge_label, "首充双倍"); assert_eq!(first_recharge_products[0].badge_label, "首充双倍");
assert_eq!(first_recharge_products[0].description, "首充送60泥点"); assert_eq!(first_recharge_products[0].description, "首充送60泥点");
let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true); let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true);
assert_eq!(repeated_recharge_products[0].bonus_points, 0); assert_eq!(repeated_recharge_products[0].bonus_points, 60);
assert_eq!(repeated_recharge_products[0].badge_label, ""); assert_eq!(repeated_recharge_products[0].badge_label, "首充双倍");
assert_eq!(repeated_recharge_products[0].description, "60泥点"); assert_eq!(repeated_recharge_products[0].description, "首充送60泥点");
assert_eq!(repeated_recharge_products[5].bonus_points, 0); assert_eq!(repeated_recharge_products[5].bonus_points, 3280);
assert_eq!(repeated_recharge_products[5].badge_label, ""); assert_eq!(repeated_recharge_products[5].badge_label, "首充双倍");
assert_eq!(repeated_recharge_products[5].description, "3280泥点"); assert_eq!(repeated_recharge_products[5].description, "首充送3280泥点");
} }
#[test] #[test]
fn build_recharge_order_input_rejects_unknown_product() { fn build_recharge_order_input_accepts_configured_product_id_later() {
let error = build_runtime_profile_recharge_order_create_input( let input = build_runtime_profile_recharge_order_create_input(
"user-1".to_string(), "user-1".to_string(),
"bad-product".to_string(), "custom-points-600".to_string(),
"mock".to_string(), "mock".to_string(),
1, 1,
) )
.expect_err("unknown product should fail"); .expect("product existence is validated against database config later");
assert_eq!(error, RuntimeProfileFieldError::UnknownRechargeProduct); assert_eq!(input.product_id, "custom-points-600");
assert_eq!(input.payment_channel, "mock");
} }
#[test] #[test]

View File

@@ -21,6 +21,12 @@ pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete";
pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable"; pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable";
pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed"; pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed";
pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled"; pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled";
pub const PROFILE_RECHARGE_PRODUCT_KIND_POINTS: &str = "points";
pub const PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP: &str = "membership";
pub const PROFILE_MEMBERSHIP_TIER_NORMAL: &str = "normal";
pub const PROFILE_MEMBERSHIP_TIER_MONTH: &str = "month";
pub const PROFILE_MEMBERSHIP_TIER_SEASON: &str = "season";
pub const PROFILE_MEMBERSHIP_TIER_YEAR: &str = "year";
pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open"; pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open";
pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_SITE: &str = "site";
pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work";
@@ -436,6 +442,33 @@ pub struct ProfileTaskConfigAdminListResponse {
pub entries: Vec<ProfileTaskConfigAdminResponse>, pub entries: Vec<ProfileTaskConfigAdminResponse>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeProductConfigAdminResponse {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: String,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: String,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub updated_by: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRechargeProductConfigAdminListResponse {
pub entries: Vec<ProfileRechargeProductConfigAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryRequest { pub struct AnalyticsMetricQueryRequest {
@@ -478,6 +511,27 @@ pub struct AdminUpsertProfileTaskConfigRequest {
pub sort_order: Option<i32>, pub sort_order: Option<i32>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileRechargeProductRequest {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: String,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
#[serde(default)]
pub badge_label: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub tier: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub sort_order: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileTaskConfigRequest { pub struct AdminDisableProfileTaskConfigRequest {

View File

@@ -165,7 +165,7 @@ use module_runtime::{
RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductConfigRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord,
@@ -185,6 +185,9 @@ use module_runtime::{
build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_create_input,
build_runtime_profile_recharge_order_get_input, build_runtime_profile_recharge_order_get_input,
build_runtime_profile_recharge_product_admin_list_input,
build_runtime_profile_recharge_product_admin_upsert_input,
build_runtime_profile_recharge_product_config_record,
build_runtime_profile_redeem_code_admin_disable_input, build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_redeem_code_admin_list_input, build_runtime_profile_redeem_code_admin_list_input,
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,

View File

@@ -309,6 +309,39 @@ impl From<module_runtime::RuntimeProfileTaskConfigAdminDisableInput>
} }
} }
impl From<module_runtime::RuntimeProfileRechargeProductAdminListInput>
for RuntimeProfileRechargeProductAdminListInput
{
fn from(input: module_runtime::RuntimeProfileRechargeProductAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeProfileRechargeProductAdminUpsertInput>
for RuntimeProfileRechargeProductAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileRechargeProductAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
product_id: input.product_id,
title: input.title,
price_cents: input.price_cents,
kind: map_runtime_profile_recharge_product_kind(input.kind),
points_amount: input.points_amount,
bonus_points: input.bonus_points,
duration_days: input.duration_days,
badge_label: input.badge_label,
description: input.description,
tier: map_runtime_profile_membership_tier(input.tier),
enabled: input.enabled,
sort_order: input.sort_order,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput> impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
for RuntimeProfileRedeemCodeAdminUpsertInput for RuntimeProfileRedeemCodeAdminUpsertInput
{ {
@@ -1157,6 +1190,40 @@ pub(crate) fn map_runtime_profile_task_config_admin_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_recharge_product_admin_list_procedure_result(
result: RuntimeProfileRechargeProductAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileRechargeProductConfigRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_recharge_product_config_record(
map_runtime_profile_recharge_product_config_snapshot(snapshot),
)
})
.collect())
}
pub(crate) fn map_runtime_profile_recharge_product_admin_procedure_result(
result: RuntimeProfileRechargeProductAdminProcedureResult,
) -> Result<RuntimeProfileRechargeProductConfigRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("recharge product config 快照"))?;
Ok(build_runtime_profile_recharge_product_config_record(
map_runtime_profile_recharge_product_config_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
result: RuntimeProfileRedeemCodeAdminProcedureResult, result: RuntimeProfileRedeemCodeAdminProcedureResult,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> { ) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
@@ -2237,6 +2304,29 @@ pub(crate) fn map_runtime_profile_recharge_product_snapshot(
} }
} }
pub(crate) fn map_runtime_profile_recharge_product_config_snapshot(
snapshot: RuntimeProfileRechargeProductConfigSnapshot,
) -> module_runtime::RuntimeProfileRechargeProductConfigSnapshot {
module_runtime::RuntimeProfileRechargeProductConfigSnapshot {
product_id: snapshot.product_id,
title: snapshot.title,
price_cents: snapshot.price_cents,
kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind),
points_amount: snapshot.points_amount,
bonus_points: snapshot.bonus_points,
duration_days: snapshot.duration_days,
badge_label: snapshot.badge_label,
description: snapshot.description,
tier: map_runtime_profile_membership_tier_back(snapshot.tier),
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_membership_benefit_snapshot( pub(crate) fn map_runtime_profile_membership_benefit_snapshot(
snapshot: RuntimeProfileMembershipBenefitSnapshot, snapshot: RuntimeProfileMembershipBenefitSnapshot,
) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { ) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot {
@@ -5037,6 +5127,19 @@ pub(crate) fn map_runtime_profile_redeem_code_mode_back(
} }
} }
pub(crate) fn map_runtime_profile_recharge_product_kind(
value: module_runtime::RuntimeProfileRechargeProductKind,
) -> crate::module_bindings::RuntimeProfileRechargeProductKind {
match value {
module_runtime::RuntimeProfileRechargeProductKind::Points => {
crate::module_bindings::RuntimeProfileRechargeProductKind::Points
}
module_runtime::RuntimeProfileRechargeProductKind::Membership => {
crate::module_bindings::RuntimeProfileRechargeProductKind::Membership
}
}
}
pub(crate) fn map_runtime_profile_recharge_product_kind_back( pub(crate) fn map_runtime_profile_recharge_product_kind_back(
value: crate::module_bindings::RuntimeProfileRechargeProductKind, value: crate::module_bindings::RuntimeProfileRechargeProductKind,
) -> module_runtime::RuntimeProfileRechargeProductKind { ) -> module_runtime::RuntimeProfileRechargeProductKind {
@@ -5050,6 +5153,25 @@ pub(crate) fn map_runtime_profile_recharge_product_kind_back(
} }
} }
pub(crate) fn map_runtime_profile_membership_tier(
value: module_runtime::RuntimeProfileMembershipTier,
) -> crate::module_bindings::RuntimeProfileMembershipTier {
match value {
module_runtime::RuntimeProfileMembershipTier::Normal => {
crate::module_bindings::RuntimeProfileMembershipTier::Normal
}
module_runtime::RuntimeProfileMembershipTier::Month => {
crate::module_bindings::RuntimeProfileMembershipTier::Month
}
module_runtime::RuntimeProfileMembershipTier::Season => {
crate::module_bindings::RuntimeProfileMembershipTier::Season
}
module_runtime::RuntimeProfileMembershipTier::Year => {
crate::module_bindings::RuntimeProfileMembershipTier::Year
}
}
}
pub(crate) fn map_runtime_profile_membership_status_back( pub(crate) fn map_runtime_profile_membership_status_back(
value: crate::module_bindings::RuntimeProfileMembershipStatus, value: crate::module_bindings::RuntimeProfileMembershipStatus,
) -> module_runtime::RuntimeProfileMembershipStatus { ) -> module_runtime::RuntimeProfileMembershipStatus {

View File

@@ -0,0 +1,61 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_recharge_product_admin_list_input_type::RuntimeProfileRechargeProductAdminListInput;
use super::runtime_profile_recharge_product_admin_list_procedure_result_type::RuntimeProfileRechargeProductAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileRechargeProductsArgs {
pub input: RuntimeProfileRechargeProductAdminListInput,
}
impl __sdk::InModule for AdminListProfileRechargeProductsArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_recharge_products`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_recharge_products {
fn admin_list_profile_recharge_products(
&self,
input: RuntimeProfileRechargeProductAdminListInput,
) {
self.admin_list_profile_recharge_products_then(input, |_, _| {});
}
fn admin_list_profile_recharge_products_then(
&self,
input: RuntimeProfileRechargeProductAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_recharge_products for super::RemoteProcedures {
fn admin_list_profile_recharge_products_then(
&self,
input: RuntimeProfileRechargeProductAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeProductAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminListProcedureResult>(
"admin_list_profile_recharge_products",
AdminListProfileRechargeProductsArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_recharge_product_admin_procedure_result_type::RuntimeProfileRechargeProductAdminProcedureResult;
use super::runtime_profile_recharge_product_admin_upsert_input_type::RuntimeProfileRechargeProductAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileRechargeProductArgs {
pub input: RuntimeProfileRechargeProductAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileRechargeProductArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_recharge_product`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_recharge_product {
fn admin_upsert_profile_recharge_product(
&self,
input: RuntimeProfileRechargeProductAdminUpsertInput,
) {
self.admin_upsert_profile_recharge_product_then(input, |_, _| {});
}
fn admin_upsert_profile_recharge_product_then(
&self,
input: RuntimeProfileRechargeProductAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_recharge_product for super::RemoteProcedures {
fn admin_upsert_profile_recharge_product_then(
&self,
input: RuntimeProfileRechargeProductAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRechargeProductAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeProductAdminProcedureResult>(
"admin_upsert_profile_recharge_product",
AdminUpsertProfileRechargeProductArgs { input },
__callback,
);
}
}

View File

@@ -11,9 +11,11 @@ pub mod acknowledge_quest_completion_reducer;
pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_disable_profile_task_config_procedure; pub mod admin_disable_profile_task_config_procedure;
pub mod admin_list_profile_invite_codes_procedure; pub mod admin_list_profile_invite_codes_procedure;
pub mod admin_list_profile_recharge_products_procedure;
pub mod admin_list_profile_redeem_codes_procedure; pub mod admin_list_profile_redeem_codes_procedure;
pub mod admin_list_profile_task_configs_procedure; pub mod admin_list_profile_task_configs_procedure;
pub mod admin_upsert_profile_invite_code_procedure; pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_recharge_product_procedure;
pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_redeem_code_procedure;
pub mod admin_upsert_profile_task_config_procedure; pub mod admin_upsert_profile_task_config_procedure;
pub mod advance_puzzle_next_level_procedure; pub mod advance_puzzle_next_level_procedure;
@@ -461,6 +463,8 @@ pub mod profile_played_world_table;
pub mod profile_played_world_type; pub mod profile_played_world_type;
pub mod profile_recharge_order_table; pub mod profile_recharge_order_table;
pub mod profile_recharge_order_type; pub mod profile_recharge_order_type;
pub mod profile_recharge_product_config_table;
pub mod profile_recharge_product_config_type;
pub mod profile_redeem_code_table; pub mod profile_redeem_code_table;
pub mod profile_redeem_code_type; pub mod profile_redeem_code_type;
pub mod profile_redeem_code_usage_table; pub mod profile_redeem_code_usage_table;
@@ -652,6 +656,11 @@ pub mod runtime_profile_recharge_order_get_input_type;
pub mod runtime_profile_recharge_order_paid_input_type; pub mod runtime_profile_recharge_order_paid_input_type;
pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_snapshot_type;
pub mod runtime_profile_recharge_order_status_type; pub mod runtime_profile_recharge_order_status_type;
pub mod runtime_profile_recharge_product_admin_list_input_type;
pub mod runtime_profile_recharge_product_admin_list_procedure_result_type;
pub mod runtime_profile_recharge_product_admin_procedure_result_type;
pub mod runtime_profile_recharge_product_admin_upsert_input_type;
pub mod runtime_profile_recharge_product_config_snapshot_type;
pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_type; pub mod runtime_profile_recharge_product_snapshot_type;
pub mod runtime_profile_redeem_code_admin_disable_input_type; pub mod runtime_profile_redeem_code_admin_disable_input_type;
@@ -857,9 +866,11 @@ pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config; pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config;
pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes; pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes;
pub use admin_list_profile_recharge_products_procedure::admin_list_profile_recharge_products;
pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes; pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes;
pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs; pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs;
pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code;
pub use admin_upsert_profile_recharge_product_procedure::admin_upsert_profile_recharge_product;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config; pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
@@ -1307,6 +1318,8 @@ pub use profile_played_world_table::*;
pub use profile_played_world_type::ProfilePlayedWorld; pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_table::*; pub use profile_recharge_order_table::*;
pub use profile_recharge_order_type::ProfileRechargeOrder; pub use profile_recharge_order_type::ProfileRechargeOrder;
pub use profile_recharge_product_config_table::*;
pub use profile_recharge_product_config_type::ProfileRechargeProductConfig;
pub use profile_redeem_code_table::*; pub use profile_redeem_code_table::*;
pub use profile_redeem_code_type::ProfileRedeemCode; pub use profile_redeem_code_type::ProfileRedeemCode;
pub use profile_redeem_code_usage_table::*; pub use profile_redeem_code_usage_table::*;
@@ -1498,6 +1511,11 @@ pub use runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrd
pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput;
pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot;
pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus;
pub use runtime_profile_recharge_product_admin_list_input_type::RuntimeProfileRechargeProductAdminListInput;
pub use runtime_profile_recharge_product_admin_list_procedure_result_type::RuntimeProfileRechargeProductAdminListProcedureResult;
pub use runtime_profile_recharge_product_admin_procedure_result_type::RuntimeProfileRechargeProductAdminProcedureResult;
pub use runtime_profile_recharge_product_admin_upsert_input_type::RuntimeProfileRechargeProductAdminUpsertInput;
pub use runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot;
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
@@ -2020,6 +2038,7 @@ pub struct DbUpdate {
profile_membership: __sdk::TableUpdate<ProfileMembership>, profile_membership: __sdk::TableUpdate<ProfileMembership>,
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>, profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
profile_recharge_order: __sdk::TableUpdate<ProfileRechargeOrder>, profile_recharge_order: __sdk::TableUpdate<ProfileRechargeOrder>,
profile_recharge_product_config: __sdk::TableUpdate<ProfileRechargeProductConfig>,
profile_redeem_code: __sdk::TableUpdate<ProfileRedeemCode>, profile_redeem_code: __sdk::TableUpdate<ProfileRedeemCode>,
profile_redeem_code_usage: __sdk::TableUpdate<ProfileRedeemCodeUsage>, profile_redeem_code_usage: __sdk::TableUpdate<ProfileRedeemCodeUsage>,
profile_referral_relation: __sdk::TableUpdate<ProfileReferralRelation>, profile_referral_relation: __sdk::TableUpdate<ProfileReferralRelation>,
@@ -2224,6 +2243,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"profile_recharge_order" => db_update.profile_recharge_order.append( "profile_recharge_order" => db_update.profile_recharge_order.append(
profile_recharge_order_table::parse_table_update(table_update)?, profile_recharge_order_table::parse_table_update(table_update)?,
), ),
"profile_recharge_product_config" => {
db_update.profile_recharge_product_config.append(
profile_recharge_product_config_table::parse_table_update(table_update)?,
)
}
"profile_redeem_code" => db_update "profile_redeem_code" => db_update
.profile_redeem_code .profile_redeem_code
.append(profile_redeem_code_table::parse_table_update(table_update)?), .append(profile_redeem_code_table::parse_table_update(table_update)?),
@@ -2627,6 +2651,12 @@ impl __sdk::DbUpdate for DbUpdate {
&self.profile_recharge_order, &self.profile_recharge_order,
) )
.with_updates_by_pk(|row| &row.order_id); .with_updates_by_pk(|row| &row.order_id);
diff.profile_recharge_product_config = cache
.apply_diff_to_table::<ProfileRechargeProductConfig>(
"profile_recharge_product_config",
&self.profile_recharge_product_config,
)
.with_updates_by_pk(|row| &row.product_id);
diff.profile_redeem_code = cache diff.profile_redeem_code = cache
.apply_diff_to_table::<ProfileRedeemCode>( .apply_diff_to_table::<ProfileRedeemCode>(
"profile_redeem_code", "profile_redeem_code",
@@ -2969,6 +2999,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_recharge_order" => db_update "profile_recharge_order" => db_update
.profile_recharge_order .profile_recharge_order
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_recharge_product_config" => db_update
.profile_recharge_product_config
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_redeem_code" => db_update "profile_redeem_code" => db_update
.profile_redeem_code .profile_redeem_code
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -3246,6 +3279,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_recharge_order" => db_update "profile_recharge_order" => db_update
.profile_recharge_order .profile_recharge_order
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_recharge_product_config" => db_update
.profile_recharge_product_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_redeem_code" => db_update "profile_redeem_code" => db_update
.profile_redeem_code .profile_redeem_code
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -3427,6 +3463,7 @@ pub struct AppliedDiff<'r> {
profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>, profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>,
profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>,
profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>, profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>,
profile_recharge_product_config: __sdk::TableAppliedDiff<'r, ProfileRechargeProductConfig>,
profile_redeem_code: __sdk::TableAppliedDiff<'r, ProfileRedeemCode>, profile_redeem_code: __sdk::TableAppliedDiff<'r, ProfileRedeemCode>,
profile_redeem_code_usage: __sdk::TableAppliedDiff<'r, ProfileRedeemCodeUsage>, profile_redeem_code_usage: __sdk::TableAppliedDiff<'r, ProfileRedeemCodeUsage>,
profile_referral_relation: __sdk::TableAppliedDiff<'r, ProfileReferralRelation>, profile_referral_relation: __sdk::TableAppliedDiff<'r, ProfileReferralRelation>,
@@ -3717,6 +3754,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.profile_recharge_order, &self.profile_recharge_order,
event, event,
); );
callbacks.invoke_table_row_callbacks::<ProfileRechargeProductConfig>(
"profile_recharge_product_config",
&self.profile_recharge_product_config,
event,
);
callbacks.invoke_table_row_callbacks::<ProfileRedeemCode>( callbacks.invoke_table_row_callbacks::<ProfileRedeemCode>(
"profile_redeem_code", "profile_redeem_code",
&self.profile_redeem_code, &self.profile_redeem_code,
@@ -4609,6 +4651,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
profile_membership_table::register_table(client_cache); profile_membership_table::register_table(client_cache);
profile_played_world_table::register_table(client_cache); profile_played_world_table::register_table(client_cache);
profile_recharge_order_table::register_table(client_cache); profile_recharge_order_table::register_table(client_cache);
profile_recharge_product_config_table::register_table(client_cache);
profile_redeem_code_table::register_table(client_cache); profile_redeem_code_table::register_table(client_cache);
profile_redeem_code_usage_table::register_table(client_cache); profile_redeem_code_usage_table::register_table(client_cache);
profile_referral_relation_table::register_table(client_cache); profile_referral_relation_table::register_table(client_cache);
@@ -4699,6 +4742,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"profile_membership", "profile_membership",
"profile_played_world", "profile_played_world",
"profile_recharge_order", "profile_recharge_order",
"profile_recharge_product_config",
"profile_redeem_code", "profile_redeem_code",
"profile_redeem_code_usage", "profile_redeem_code_usage",
"profile_referral_relation", "profile_referral_relation",

View File

@@ -0,0 +1,171 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::profile_recharge_product_config_type::ProfileRechargeProductConfig;
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `profile_recharge_product_config`.
///
/// Obtain a handle from the [`ProfileRechargeProductConfigTableAccess::profile_recharge_product_config`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_recharge_product_config()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_recharge_product_config().on_insert(...)`.
pub struct ProfileRechargeProductConfigTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileRechargeProductConfig>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_recharge_product_config`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileRechargeProductConfigTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileRechargeProductConfigTableHandle`], which mediates access to the table `profile_recharge_product_config`.
fn profile_recharge_product_config(&self) -> ProfileRechargeProductConfigTableHandle<'_>;
}
impl ProfileRechargeProductConfigTableAccess for super::RemoteTables {
fn profile_recharge_product_config(&self) -> ProfileRechargeProductConfigTableHandle<'_> {
ProfileRechargeProductConfigTableHandle {
imp: self
.imp
.get_table::<ProfileRechargeProductConfig>("profile_recharge_product_config"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileRechargeProductConfigInsertCallbackId(__sdk::CallbackId);
pub struct ProfileRechargeProductConfigDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileRechargeProductConfigTableHandle<'ctx> {
type Row = ProfileRechargeProductConfig;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = ProfileRechargeProductConfig> + '_ {
self.imp.iter()
}
type InsertCallbackId = ProfileRechargeProductConfigInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileRechargeProductConfigInsertCallbackId {
ProfileRechargeProductConfigInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileRechargeProductConfigInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileRechargeProductConfigDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileRechargeProductConfigDeleteCallbackId {
ProfileRechargeProductConfigDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileRechargeProductConfigDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileRechargeProductConfigUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeProductConfigTableHandle<'ctx> {
type UpdateCallbackId = ProfileRechargeProductConfigUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileRechargeProductConfigUpdateCallbackId {
ProfileRechargeProductConfigUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileRechargeProductConfigUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `product_id` unique index on the table `profile_recharge_product_config`,
/// which allows point queries on the field of the same name
/// via the [`ProfileRechargeProductConfigProductIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_recharge_product_config().product_id().find(...)`.
pub struct ProfileRechargeProductConfigProductIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileRechargeProductConfig, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileRechargeProductConfigTableHandle<'ctx> {
/// Get a handle on the `product_id` unique index on the table `profile_recharge_product_config`.
pub fn product_id(&self) -> ProfileRechargeProductConfigProductIdUnique<'ctx> {
ProfileRechargeProductConfigProductIdUnique {
imp: self.imp.get_unique_constraint::<String>("product_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileRechargeProductConfigProductIdUnique<'ctx> {
/// Find the subscribed row whose `product_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileRechargeProductConfig> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache
.get_or_make_table::<ProfileRechargeProductConfig>("profile_recharge_product_config");
_table.add_unique_constraint::<String>("product_id", |row| &row.product_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileRechargeProductConfig>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<ProfileRechargeProductConfig>",
"TableUpdate",
)
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileRechargeProductConfig`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_recharge_product_configQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileRechargeProductConfig`.
fn profile_recharge_product_config(
&self,
) -> __sdk::__query_builder::Table<ProfileRechargeProductConfig>;
}
impl profile_recharge_product_configQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_recharge_product_config(
&self,
) -> __sdk::__query_builder::Table<ProfileRechargeProductConfig> {
__sdk::__query_builder::Table::new("profile_recharge_product_config")
}
}

View File

@@ -0,0 +1,101 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileRechargeProductConfig {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: __sdk::Timestamp,
pub updated_by: String,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileRechargeProductConfig {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileRechargeProductConfig`.
///
/// Provides typed access to columns for query building.
pub struct ProfileRechargeProductConfigCols {
pub product_id: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub title: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub price_cents: __sdk::__query_builder::Col<ProfileRechargeProductConfig, u64>,
pub kind: __sdk::__query_builder::Col<
ProfileRechargeProductConfig,
RuntimeProfileRechargeProductKind,
>,
pub points_amount: __sdk::__query_builder::Col<ProfileRechargeProductConfig, u64>,
pub bonus_points: __sdk::__query_builder::Col<ProfileRechargeProductConfig, u64>,
pub duration_days: __sdk::__query_builder::Col<ProfileRechargeProductConfig, u32>,
pub badge_label: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub description: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub tier:
__sdk::__query_builder::Col<ProfileRechargeProductConfig, RuntimeProfileMembershipTier>,
pub enabled: __sdk::__query_builder::Col<ProfileRechargeProductConfig, bool>,
pub sort_order: __sdk::__query_builder::Col<ProfileRechargeProductConfig, i32>,
pub created_by: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub created_at: __sdk::__query_builder::Col<ProfileRechargeProductConfig, __sdk::Timestamp>,
pub updated_by: __sdk::__query_builder::Col<ProfileRechargeProductConfig, String>,
pub updated_at: __sdk::__query_builder::Col<ProfileRechargeProductConfig, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileRechargeProductConfig {
type Cols = ProfileRechargeProductConfigCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileRechargeProductConfigCols {
product_id: __sdk::__query_builder::Col::new(table_name, "product_id"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
price_cents: __sdk::__query_builder::Col::new(table_name, "price_cents"),
kind: __sdk::__query_builder::Col::new(table_name, "kind"),
points_amount: __sdk::__query_builder::Col::new(table_name, "points_amount"),
bonus_points: __sdk::__query_builder::Col::new(table_name, "bonus_points"),
duration_days: __sdk::__query_builder::Col::new(table_name, "duration_days"),
badge_label: __sdk::__query_builder::Col::new(table_name, "badge_label"),
description: __sdk::__query_builder::Col::new(table_name, "description"),
tier: __sdk::__query_builder::Col::new(table_name, "tier"),
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileRechargeProductConfig`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileRechargeProductConfigIxCols {
pub product_id: __sdk::__query_builder::IxCol<ProfileRechargeProductConfig, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileRechargeProductConfig {
type IxCols = ProfileRechargeProductConfigIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileRechargeProductConfigIxCols {
product_id: __sdk::__query_builder::IxCol::new(table_name, "product_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeProductConfig {}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileRechargeProductAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRechargeProductConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRechargeProductAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRechargeProductConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRechargeProductAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,31 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductAdminUpsertInput {
pub admin_user_id: String,
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRechargeProductAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,33 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_membership_tier_type::RuntimeProfileMembershipTier;
use super::runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRechargeProductConfigSnapshot {
pub product_id: String,
pub title: String,
pub price_cents: u64,
pub kind: RuntimeProfileRechargeProductKind,
pub points_amount: u64,
pub bonus_points: u64,
pub duration_days: u32,
pub badge_label: String,
pub description: String,
pub tier: RuntimeProfileMembershipTier,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileRechargeProductConfigSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -657,6 +657,78 @@ impl SpacetimeClient {
.await .await
} }
pub async fn admin_list_profile_recharge_products(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileRechargeProductConfigRecord>, SpacetimeClientError> {
let procedure_input =
build_runtime_profile_recharge_product_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_recharge_products_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_recharge_product_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn admin_upsert_profile_recharge_product(
&self,
admin_user_id: String,
product_id: String,
title: String,
price_cents: u64,
kind: module_runtime::RuntimeProfileRechargeProductKind,
points_amount: u64,
bonus_points: u64,
duration_days: u32,
badge_label: String,
description: String,
tier: module_runtime::RuntimeProfileMembershipTier,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileRechargeProductConfigRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_recharge_product_admin_upsert_input(
admin_user_id,
product_id,
title,
price_cents,
kind,
points_amount,
bonus_points,
duration_days,
badge_label,
description,
tier,
enabled,
sort_order,
updated_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_recharge_product_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_recharge_product_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_upsert_profile_redeem_code( pub async fn admin_upsert_profile_redeem_code(
&self, &self,
admin_user_id: String, admin_user_id: String,

View File

@@ -193,6 +193,7 @@ macro_rules! migration_tables {
public_work_play_daily_stat, public_work_play_daily_stat,
public_work_like, public_work_like,
profile_membership, profile_membership,
profile_recharge_product_config,
profile_recharge_order, profile_recharge_order,
profile_feedback_submission, profile_feedback_submission,
profile_save_archive, profile_save_archive,

View File

@@ -6,6 +6,7 @@ const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20; const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration"; const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task"; 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_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile"; const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
@@ -328,6 +329,28 @@ pub struct ProfileMembership {
pub(crate) updated_at: Timestamp, pub(crate) updated_at: Timestamp,
} }
#[spacetimedb::table(accessor = profile_recharge_product_config)]
#[derive(Clone)]
pub struct ProfileRechargeProductConfig {
#[primary_key]
pub(crate) product_id: String,
pub(crate) title: String,
pub(crate) price_cents: u64,
pub(crate) kind: RuntimeProfileRechargeProductKind,
pub(crate) points_amount: u64,
pub(crate) bonus_points: u64,
pub(crate) duration_days: u32,
pub(crate) badge_label: String,
pub(crate) description: String,
pub(crate) tier: RuntimeProfileMembershipTier,
pub(crate) enabled: bool,
pub(crate) sort_order: i32,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table( #[spacetimedb::table(
accessor = profile_recharge_order, accessor = profile_recharge_order,
index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])), index(accessor = by_profile_recharge_order_user_id, btree(columns = [user_id])),
@@ -655,6 +678,44 @@ pub fn admin_disable_profile_task_config(
} }
} }
#[spacetimedb::procedure]
pub fn admin_list_profile_recharge_products(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeProductAdminListInput,
) -> RuntimeProfileRechargeProductAdminListProcedureResult {
match ctx.try_with_tx(|tx| list_profile_recharge_product_config_snapshots(tx, input.clone())) {
Ok(entries) => RuntimeProfileRechargeProductAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRechargeProductAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_recharge_product(
ctx: &mut ProcedureContext,
input: RuntimeProfileRechargeProductAdminUpsertInput,
) -> RuntimeProfileRechargeProductAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_recharge_product_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileRechargeProductAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileRechargeProductAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。 // 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn grant_new_user_registration_wallet_reward( pub fn grant_new_user_registration_wallet_reward(
@@ -1454,6 +1515,22 @@ fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str)
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn point_recharge_display_is_resolved_per_product() {
let products = runtime_profile_recharge_point_products();
let repeated = resolve_profile_recharge_product_display(products[0].clone(), true);
let untouched = resolve_profile_recharge_product_display(products[1].clone(), false);
assert_eq!(repeated.product_id, "points_60");
assert_eq!(repeated.bonus_points, 0);
assert_eq!(repeated.badge_label, "");
assert_eq!(repeated.description, "60泥点");
assert_eq!(untouched.product_id, "points_180");
assert_eq!(untouched.bonus_points, 180);
assert_eq!(untouched.badge_label, "首充双倍");
assert_eq!(untouched.description, "首充送180泥点");
}
#[test] #[test]
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() { 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(true));
@@ -2091,8 +2168,8 @@ fn create_profile_recharge_order_record(
input.created_at_micros, input.created_at_micros,
) )
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let product = runtime_profile_recharge_product_by_id(&validated_input.product_id) let product = enabled_profile_recharge_product_by_id(ctx, &validated_input.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?; .ok_or_else(|| "recharge.product_id 不存在或已下架".to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let should_settle_immediately = let should_settle_immediately =
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK; validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
@@ -2201,7 +2278,7 @@ fn mark_profile_recharge_order_paid_record(
return Err("profile_recharge_order 当前状态不能确认支付".to_string()); return Err("profile_recharge_order 当前状态不能确认支付".to_string());
} }
let product = runtime_profile_recharge_product_by_id(&order.product_id) let product = profile_recharge_product_by_id(ctx, &order.product_id)
.ok_or_else(|| "recharge.product_id 不存在".to_string())?; .ok_or_else(|| "recharge.product_id 不存在".to_string())?;
let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros); let paid_at = Timestamp::from_micros_since_unix_epoch(validated_input.paid_at_micros);
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase( let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
@@ -2238,7 +2315,7 @@ fn apply_profile_recharge_purchase(
) -> Result<(i64, Option<Timestamp>), String> { ) -> Result<(i64, Option<Timestamp>), String> {
match product.kind { match product.kind {
RuntimeProfileRechargeProductKind::Points => { RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_points_recharged(ctx, user_id); let has_recharged = has_profile_product_recharged(ctx, user_id, &product.product_id);
let points_delta = 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( apply_profile_wallet_delta(
@@ -2907,6 +2984,7 @@ fn build_profile_recharge_center_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
) -> RuntimeProfileRechargeCenterSnapshot { ) -> RuntimeProfileRechargeCenterSnapshot {
ensure_default_profile_recharge_product_config(ctx);
let wallet_balance = ctx let wallet_balance = ctx
.db .db
.profile_dashboard_state() .profile_dashboard_state()
@@ -2916,13 +2994,31 @@ fn build_profile_recharge_center_snapshot(
.unwrap_or(0); .unwrap_or(0);
let has_points_recharged = has_profile_points_recharged(ctx, user_id); 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 { RuntimeProfileRechargeCenterSnapshot {
user_id: user_id.to_string(), user_id: user_id.to_string(),
wallet_balance, wallet_balance,
membership: build_profile_membership_snapshot(ctx, user_id), membership: build_profile_membership_snapshot(ctx, user_id),
point_products: resolve_runtime_profile_recharge_point_products(has_points_recharged), point_products,
membership_products: runtime_profile_recharge_membership_products(), membership_products,
benefits: runtime_profile_membership_benefits(), benefits: runtime_profile_membership_benefits(),
latest_order: latest_profile_recharge_order(ctx, user_id) latest_order: latest_profile_recharge_order(ctx, user_id)
.map(|row| build_profile_recharge_order_snapshot_from_row(&row)), .map(|row| build_profile_recharge_order_snapshot_from_row(&row)),
@@ -3052,6 +3148,21 @@ fn list_profile_task_config_snapshots(
Ok(entries) Ok(entries)
} }
fn list_profile_recharge_product_config_snapshots(
ctx: &ReducerContext,
input: RuntimeProfileRechargeProductAdminListInput,
) -> Result<Vec<RuntimeProfileRechargeProductConfigSnapshot>, 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( fn admin_list_profile_redeem_code_records(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminListInput, input: RuntimeProfileRedeemCodeAdminListInput,
@@ -3097,6 +3208,74 @@ fn admin_list_profile_invite_code_records(
Ok(entries) Ok(entries)
} }
fn upsert_profile_recharge_product_config_record(
ctx: &ReducerContext,
input: RuntimeProfileRechargeProductAdminUpsertInput,
) -> Result<RuntimeProfileRechargeProductConfigSnapshot, String> {
let validated_input = build_runtime_profile_recharge_product_admin_upsert_input(
input.admin_user_id,
input.product_id,
input.title,
input.price_cents,
input.kind,
input.points_amount,
input.bonus_points,
input.duration_days,
input.badge_label,
input.description,
input.tier,
input.enabled,
input.sort_order,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
ensure_default_profile_recharge_product_config(ctx);
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_recharge_product_config()
.product_id()
.find(&validated_input.product_id);
if let Some(row) = existing.as_ref() {
ctx.db
.profile_recharge_product_config()
.product_id()
.delete(&row.product_id);
}
let inserted = ctx
.db
.profile_recharge_product_config()
.insert(ProfileRechargeProductConfig {
product_id: validated_input.product_id,
title: validated_input.title,
price_cents: validated_input.price_cents,
kind: validated_input.kind,
points_amount: validated_input.points_amount,
bonus_points: validated_input.bonus_points,
duration_days: validated_input.duration_days,
badge_label: validated_input.badge_label,
description: validated_input.description,
tier: validated_input.tier,
enabled: validated_input.enabled,
sort_order: validated_input.sort_order,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
});
Ok(build_profile_recharge_product_config_snapshot_from_row(
&inserted,
))
}
fn upsert_profile_task_config_record( fn upsert_profile_task_config_record(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminUpsertInput, input: RuntimeProfileTaskConfigAdminUpsertInput,
@@ -3518,6 +3697,96 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig
}) })
} }
fn ensure_default_profile_recharge_product_config(ctx: &ReducerContext) {
if ctx.db.profile_recharge_product_config().count() > 0 {
return;
}
let now = ctx.timestamp;
for (sort_order, product) in runtime_profile_recharge_point_products()
.into_iter()
.chain(runtime_profile_recharge_membership_products())
.enumerate()
{
ctx.db
.profile_recharge_product_config()
.insert(ProfileRechargeProductConfig {
product_id: product.product_id,
title: product.title,
price_cents: product.price_cents,
kind: product.kind,
points_amount: product.points_amount,
bonus_points: product.bonus_points,
duration_days: product.duration_days,
badge_label: product.badge_label,
description: product.description,
tier: product.tier,
enabled: true,
sort_order: sort_order as i32,
created_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
created_at: now,
updated_by: PROFILE_RECHARGE_PRODUCT_SYSTEM_USER_ID.to_string(),
updated_at: now,
});
}
}
fn profile_recharge_product_config_rows(
ctx: &ReducerContext,
include_disabled: bool,
) -> Vec<ProfileRechargeProductConfig> {
ensure_default_profile_recharge_product_config(ctx);
let mut rows = ctx
.db
.profile_recharge_product_config()
.iter()
.filter(|row| include_disabled || row.enabled)
.collect::<Vec<_>>();
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<RuntimeProfileRechargeProductSnapshot> {
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<RuntimeProfileRechargeProductSnapshot> {
ensure_default_profile_recharge_product_config(ctx);
ctx.db
.profile_recharge_product_config()
.product_id()
.find(&product_id.to_string())
.filter(|row| row.enabled)
.map(|row| build_profile_recharge_product_snapshot_from_config_row(&row))
}
fn resolve_profile_recharge_product_display(
mut product: RuntimeProfileRechargeProductSnapshot,
has_product_recharged: bool,
) -> RuntimeProfileRechargeProductSnapshot {
if product.kind == RuntimeProfileRechargeProductKind::Points && has_product_recharged {
product.bonus_points = 0;
product.badge_label.clear();
product.description = product.title.clone();
}
product
}
fn build_profile_membership_snapshot( fn build_profile_membership_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
@@ -3754,9 +4023,19 @@ fn apply_profile_wallet_signed_delta(
} }
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db.profile_wallet_ledger().iter().any(|row| { ctx.db.profile_recharge_order().iter().any(|row| {
row.user_id == user_id row.user_id == user_id
&& row.source_type == RuntimeProfileWalletLedgerSourceType::PointsRecharge && row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
})
}
fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool {
ctx.db.profile_recharge_order().iter().any(|row| {
row.user_id == user_id
&& row.product_id == product_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
}) })
} }
@@ -3894,6 +4173,46 @@ fn build_profile_task_config_snapshot_from_row(
} }
} }
fn build_profile_recharge_product_config_snapshot_from_row(
row: &ProfileRechargeProductConfig,
) -> RuntimeProfileRechargeProductConfigSnapshot {
RuntimeProfileRechargeProductConfigSnapshot {
product_id: row.product_id.clone(),
title: row.title.clone(),
price_cents: row.price_cents,
kind: row.kind,
points_amount: row.points_amount,
bonus_points: row.bonus_points,
duration_days: row.duration_days,
badge_label: row.badge_label.clone(),
description: row.description.clone(),
tier: row.tier,
enabled: row.enabled,
sort_order: row.sort_order,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_product_snapshot_from_config_row(
row: &ProfileRechargeProductConfig,
) -> RuntimeProfileRechargeProductSnapshot {
RuntimeProfileRechargeProductSnapshot {
product_id: row.product_id.clone(),
title: row.title.clone(),
price_cents: row.price_cents,
kind: row.kind,
points_amount: row.points_amount,
bonus_points: row.bonus_points,
duration_days: row.duration_days,
badge_label: row.badge_label.clone(),
description: row.description.clone(),
tier: row.tier,
}
}
fn build_profile_recharge_order_snapshot_from_row( fn build_profile_recharge_order_snapshot_from_row(
row: &ProfileRechargeOrder, row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderSnapshot { ) -> RuntimeProfileRechargeOrderSnapshot {

View File

@@ -1039,7 +1039,7 @@ test('profile recharge modal buys points through mock channel outside mini progr
expect(onRechargeSuccess).toHaveBeenCalledTimes(1); expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
}); });
test('profile recharge modal hides first bonus display after points recharge', async () => { test('profile recharge modal trusts per-product first bonus display after points recharge', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({ mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({
walletBalance: 60, walletBalance: 60,
@@ -1076,8 +1076,8 @@ test('profile recharge modal hides first bonus display after points recharge', a
const rechargeDialog = await screen.findByText('账户充值'); const rechargeDialog = await screen.findByText('账户充值');
expect(rechargeDialog).toBeTruthy(); expect(rechargeDialog).toBeTruthy();
expect(screen.getByRole('button', { name: /60/u })).toBeTruthy(); expect(screen.getByRole('button', { name: /60/u })).toBeTruthy();
expect(screen.queryByText('首充双倍')).toBeNull(); expect(screen.getByText('首充双倍')).toBeTruthy();
expect(screen.queryByText('60+60泥点')).toBeNull(); expect(screen.getByText('60+60泥点')).toBeTruthy();
}); });
test('profile recharge modal posts requestPayment params in mini program web-view', async () => { test('profile recharge modal posts requestPayment params in mini program web-view', async () => {

View File

@@ -2498,20 +2498,16 @@ async function confirmWechatRechargeOrderUntilSettled(
function RechargeProductCard({ function RechargeProductCard({
product, product,
hasPointsRecharged,
submittingProductId, submittingProductId,
onBuy, onBuy,
}: { }: {
product: ProfileRechargeProduct; product: ProfileRechargeProduct;
hasPointsRecharged: boolean;
submittingProductId: string | null; submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void; onBuy: (product: ProfileRechargeProduct) => void;
}) { }) {
const submitting = submittingProductId === product.productId; const submitting = submittingProductId === product.productId;
const effectiveBonusPoints = const effectiveBonusPoints = product.bonusPoints;
product.kind === 'points' && hasPointsRecharged ? 0 : product.bonusPoints; const badgeLabel = product.badgeLabel;
const badgeLabel =
product.kind === 'points' && hasPointsRecharged ? '' : product.badgeLabel;
const value = const value =
product.kind === 'points' product.kind === 'points'
? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点` ? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点`
@@ -2646,7 +2642,6 @@ function ProfileRechargeModal({
<RechargeProductCard <RechargeProductCard
key={product.productId} key={product.productId}
product={product} product={product}
hasPointsRecharged={center?.hasPointsRecharged === true}
submittingProductId={submittingProductId} submittingProductId={submittingProductId}
onBuy={onBuy} onBuy={onBuy}
/> />