master #14

Merged
kdletters merged 226 commits from master into release 2026-05-13 13:23:09 +08:00
78 changed files with 4073 additions and 420 deletions
Showing only changes of commit 104e19d257 - Show all commits

View File

@@ -16,6 +16,14 @@
---
## 2026-05-07 server-rs Cargo 依赖集中到 workspace
- 背景:`server-rs` 多 crate 已稳定成 DDD workspace成员 `Cargo.toml` 中重复散写第三方版本和本地 path 依赖,升级 SpacetimeDB SDK、`serde``reqwest``tokio` 等依赖时容易漂移。
- 决策:`server-rs/Cargo.toml``[workspace.dependencies]` 统一维护第三方依赖版本和 workspace 内部 crate path成员 crate 默认使用 `{ workspace = true }`,只保留自身 feature、optional 或 target-specific 差异;不再新增 `sha1`OSS 与阿里云 OpenAPI 签名统一走 `sha2::Sha256` 对应的 V4/V3 口径。
- 影响范围:`server-rs/Cargo.toml`、所有 `server-rs/crates/*/Cargo.toml``platform-oss``platform-auth`、后续新增 Rust crate 或新增 Rust 依赖的开发流程。
- 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。
- 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`
## 2026-05-06 Maincloud 历史残留引用禁止再使用
- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*`
@@ -24,6 +32,14 @@
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md``docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`
## 2026-05-05 新手引导首版复用拼图本地运行时
- 背景:首次打开产品的新用户需要先体验输入想法、生成拼图、通关、登录保存、回到首页的闭环,但首版不应引入新的持久化表或独立玩法运行时。
- 决策:未登录首次访问由前端 localStorage 标记触发;生成入口走公开 BFF `POST /api/runtime/puzzle/onboarding/generate` 生成 1 关临时拼图;登录后保存走鉴权 BFF `POST /api/runtime/puzzle/onboarding/save`,由服务端创建当前用户拼图 agent session 并更新其草稿作品 profile游玩阶段复用现有本地拼图运行时。
- 影响范围:平台入口首屏、新手引导 PRD、拼图 BFF、拼图作品契约与前端 puzzle runtime。
- 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。
- 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes并需要独立拉取仓库、修改代码、本地测试团队希望形成共享的长期项目记忆。

View File

@@ -69,6 +69,14 @@
- 验证:请求返回 JSON相关页面不再出现 HTML parse 错误。
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`
## 拼图 APIMart 图片生成密钥不能复用 DashScope / ARK key
- 现象:拼图新手引导或拼图创作点击生成后返回 `APIMart 图片生成密钥未配置`
- 原因:拼图 `gpt-image-2` / `nanobanana2` 图片生成已按技术方案统一走 APIMart后端只读取 `APIMART_BASE_URL``APIMART_API_KEY``APIMART_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY``LLM_API_KEY``ARK_API_KEY` 兜底。
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `APIMART_API_KEY`,不要提交到 Git填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md``.codex/skills/gpt-image-2-apimart/SKILL.md`
## Rust 冷编译导致 api-server 健康检查误超时
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`

View File

@@ -81,6 +81,10 @@ DDD 分层边界以 `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`SpacetimeDB 模块产物继续走 `spacetime build` / 发布链路。
Cargo 依赖口径:第三方依赖版本和 workspace 内部 crate path 统一维护在 `server-rs/Cargo.toml``[workspace.dependencies]`,成员 crate 默认继承 workspace 依赖,只保留自身 `features``optional` 或 target-specific 差异。
Rust 加密摘要依赖口径:新代码不再引入 `sha1`OSS V4 签名、阿里云 OpenAPI V3 签名和 refresh session token 摘要统一使用 `sha2::Sha256`
## SpacetimeDB 表域总览
`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 为持续维护入口。当前表域包括:
@@ -135,4 +139,5 @@ DDD 分层边界以 `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
- 契约与路由矩阵:`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`
- SpacetimeDB 表结构变更约束:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`
- SpacetimeDB 表目录:`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
- Rust workspace 依赖集中配置:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`
- 生产部署计划:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`

View File

@@ -6,6 +6,8 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
@@ -135,6 +137,16 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
});
}
export function listAdminTrackingEvents(
token: string,
query: AdminTrackingEventListQuery = {},
) {
return request<AdminTrackingEventListResponse>(
`/admin/api/tracking/events${buildQueryString(query)}`,
{token},
);
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
@@ -232,6 +244,30 @@ function buildRequestUrl(path: string) {
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
}
function buildQueryString(query: AdminTrackingEventListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'eventKey', query.eventKey);
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'scopeKind', query.scopeKind);
appendQueryParam(params, 'scopeId', query.scopeId);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function appendQueryParam(
params: URLSearchParams,
key: string,
value: string | null | undefined,
) {
const trimmed = value?.trim();
if (trimmed) {
params.set(key, trimmed);
}
}
function parseJsonResponse(responseText: string): unknown {
if (!responseText.trim()) {
return null;

View File

@@ -109,6 +109,14 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export interface AdminTrackingEventListQuery {
eventKey?: string;
userId?: string;
scopeKind?: TrackingScopeKind | '';
scopeId?: string;
limit?: number;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
@@ -199,3 +207,22 @@ export interface ProfileTaskConfigAdminResponse {
export interface ProfileTaskConfigAdminListResponse {
entries: ProfileTaskConfigAdminResponse[];
}
export interface AdminTrackingEventEntryPayload {
eventId: string;
eventKey: string;
eventTitle: string;
scopeKind: TrackingScopeKind | string;
scopeId: string;
dayKey: number;
userId?: string | null;
ownerUserId?: string | null;
profileId?: string | null;
moduleKey?: string | null;
metadataJson: string;
occurredAt: string;
}
export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}

View File

@@ -23,6 +23,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
@@ -162,6 +163,12 @@ export function AdminApp() {
{routeId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tracking' ? (
<AdminTrackingEventsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
<AdminRedeemCodePage
result={redeemResult}

View File

@@ -4,6 +4,7 @@ import {
LogOut,
ShieldCheck,
ListChecks,
Table2,
TicketCheck,
TicketPercent,
} from 'lucide-react';
@@ -24,6 +25,7 @@ interface AdminShellProps {
const routeIcons = {
overview: LayoutDashboard,
debug: Bug,
tracking: Table2,
redeem: TicketPercent,
invite: TicketCheck,
tasks: ListChecks,

View File

@@ -1,4 +1,4 @@
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks';
export type AdminRouteId = 'overview' | 'debug' | 'tracking' | 'redeem' | 'invite' | 'tasks';
export interface AdminRouteDefinition {
id: AdminRouteId;
@@ -9,6 +9,7 @@ export interface AdminRouteDefinition {
export const adminRoutes: AdminRouteDefinition[] = [
{id: 'overview', label: '总览', hash: '#overview'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},

View File

@@ -0,0 +1,438 @@
import {Download, Eye, RefreshCcw, Search, X} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {listAdminTrackingEvents} from '../api/adminApiClient';
import type {
AdminTrackingEventEntryPayload,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
interface AdminTrackingEventsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const scopeKindOptions: Array<{value: TrackingScopeKind | ''; label: string}> = [
{value: '', label: '全部'},
{value: 'site', label: 'site'},
{value: 'work', label: 'work'},
{value: 'module', label: 'module'},
{value: 'user', label: 'user'},
];
const exportColumns: Array<{
key: keyof AdminTrackingEventEntryPayload;
label: string;
}> = [
{key: 'eventId', label: '事件 ID'},
{key: 'eventKey', label: 'Event Key'},
{key: 'eventTitle', label: '事件名称'},
{key: 'scopeKind', label: 'Scope Kind'},
{key: 'scopeId', label: 'Scope ID'},
{key: 'dayKey', label: 'Day Key'},
{key: 'userId', label: 'User ID'},
{key: 'ownerUserId', label: 'Owner User ID'},
{key: 'profileId', label: 'Profile ID'},
{key: 'moduleKey', label: 'Module Key'},
{key: 'metadataJson', label: 'Metadata JSON'},
{key: 'occurredAt', label: '发生时间'},
];
export function AdminTrackingEventsPage({
token,
onUnauthorized,
}: AdminTrackingEventsPageProps) {
const [entries, setEntries] = useState<AdminTrackingEventEntryPayload[]>([]);
const [eventKey, setEventKey] = useState('');
const [userId, setUserId] = useState('');
const [scopeKind, setScopeKind] = useState<TrackingScopeKind | ''>('');
const [scopeId, setScopeId] = useState('');
const [limit, setLimit] = useState('200');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [detailEntry, setDetailEntry] =
useState<AdminTrackingEventEntryPayload | null>(null);
useEffect(() => {
void refreshTrackingEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const filteredEventDefinitions = useMemo(
() => filterAdminTrackingEventDefinitions(eventKey),
[eventKey],
);
async function refreshTrackingEvents() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminTrackingEvents(token, {
eventKey,
userId,
scopeKind,
scopeId,
limit: parseLimit(limit),
});
setEntries(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void refreshTrackingEvents();
}
function handleExport() {
if (!entries.length) {
setErrorMessage('当前没有可导出的埋点数据');
return;
}
exportTrackingEventsAsExcel(entries);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshTrackingEvents}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
<button
className="admin-primary-button"
disabled={!entries.length}
type="button"
onClick={handleExport}
>
<Download size={17} aria-hidden="true" />
<span> Excel</span>
</button>
</div>
</div>
<form className="admin-panel admin-form" onSubmit={handleSearch}>
<div className="admin-filter-grid">
<label className="admin-field">
<span>Event Key</span>
<input
list="admin-tracking-event-keys"
placeholder="全部"
value={eventKey}
onChange={(event) => setEventKey(event.target.value)}
/>
<datalist id="admin-tracking-event-keys">
{filteredEventDefinitions.map((definition) => (
<option key={definition.key} value={definition.key}>
{definition.title}
</option>
))}
</datalist>
</label>
<label className="admin-field">
<span>User ID</span>
<input
placeholder="全部"
value={userId}
onChange={(event) => setUserId(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Scope Kind</span>
<select
value={scopeKind}
onChange={(event) =>
setScopeKind(event.target.value as TrackingScopeKind | '')
}
>
{scopeKindOptions.map((option) => (
<option key={option.value || 'all'} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span>Scope ID</span>
<input
placeholder="全部"
value={scopeId}
onChange={(event) => setScopeId(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button className="admin-secondary-button" disabled={isLoading} type="submit">
<Search size={17} aria-hidden="true" />
<span>{isLoading ? '查询中' : '查询'}</span>
</button>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div 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-wide">
<thead>
<tr>
<th></th>
<th>Scope</th>
<th></th>
<th></th>
<th>Metadata</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.length ? (
entries.map((entry) => (
<tr key={entry.eventId}>
<td>
<strong>{resolveEventTitle(entry)}</strong>
<small>{entry.eventKey}</small>
<small>{entry.eventId}</small>
</td>
<td>
<span className="admin-status admin-status-pending">
{entry.scopeKind}
</span>
<small>{entry.scopeId || '-'}</small>
<small>dayKey: {entry.dayKey}</small>
</td>
<td>
{entry.userId || '-'}
<small>owner: {entry.ownerUserId || '-'}</small>
</td>
<td>
{entry.profileId || '-'}
<small>module: {entry.moduleKey || '-'}</small>
</td>
<td>
<pre className="admin-json-preview">
{formatMetadataJson(entry.metadataJson)}
</pre>
</td>
<td>{formatOccurredAt(entry.occurredAt)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={7}>{isLoading ? '正在加载' : '暂无数据'}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{detailEntry ? (
<TrackingEventDetailPanel
entry={detailEntry}
onClose={() => setDetailEntry(null)}
/>
) : null}
</section>
);
}
function TrackingEventDetailPanel({
entry,
onClose,
}: {
entry: AdminTrackingEventEntryPayload;
onClose: () => void;
}) {
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="埋点详情"
className="admin-detail-panel"
role="dialog"
>
<div className="admin-panel-heading">
<h3>{resolveEventTitle(entry)}</h3>
<button
aria-label="关闭详情"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<dl className="admin-info-list admin-detail-list">
{exportColumns.map((column) => (
<div key={column.key}>
<dt>{column.label}</dt>
<dd>
{column.key === 'metadataJson' ? (
<pre className="admin-code-block">
{formatMetadataJson(entry.metadataJson)}
</pre>
) : (
formatExportCell(entry[column.key], column.key) || '-'
)}
</dd>
</div>
))}
</dl>
</section>
</div>
);
}
function parseLimit(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed)) {
return 200;
}
return Math.min(Math.max(parsed, 1), 1000);
}
function resolveEventTitle(entry: AdminTrackingEventEntryPayload) {
return (
findAdminTrackingEventDefinition(entry.eventKey)?.title ||
entry.eventTitle ||
entry.eventKey
);
}
function formatMetadataJson(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return '-';
}
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
} catch {
return trimmed;
}
}
function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) {
const tableRows = [
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
...entries.map((entry) =>
exportColumns
.map(
(column) =>
`<td style="mso-number-format:'\\@';">${escapeHtml(formatExportCell(entry[column.key], column.key))}</td>`,
)
.join(''),
),
];
const html = `\uFEFF<html><head><meta charset="UTF-8" /></head><body><table>${tableRows
.map((row) => `<tr>${row}</tr>`)
.join('')}</table></body></html>`;
const blob = new Blob([html], {type: 'application/vnd.ms-excel;charset=utf-8'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `tracking-events-${buildTimestamp()}.xls`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function formatExportCell(value: unknown, key?: keyof AdminTrackingEventEntryPayload) {
if (value === null || typeof value === 'undefined') {
return '';
}
if (key === 'occurredAt') {
return formatOccurredAt(String(value));
}
return String(value);
}
function formatOccurredAt(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return '-';
}
if (/^\d+$/.test(trimmed)) {
const micros = Number(trimmed);
if (Number.isSafeInteger(micros)) {
return formatDateTime(new Date(Math.floor(micros / 1000)));
}
}
const parsed = new Date(trimmed);
if (!Number.isNaN(parsed.getTime())) {
return formatDateTime(parsed);
}
return trimmed;
}
function formatDateTime(date: Date) {
const pad = (value: number, size = 2) => String(value).padStart(size, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildTimestamp() {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, '0');
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
'-',
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('');
}

View File

@@ -216,6 +216,10 @@ button:disabled {
margin: 0 auto;
}
.admin-page-wide {
max-width: 1480px;
}
.admin-page-heading,
.admin-panel-heading,
.admin-subsection-heading {
@@ -291,6 +295,20 @@ button:disabled {
align-items: end;
}
.admin-filter-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr)) auto;
gap: 12px;
align-items: end;
}
.admin-action-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.admin-field {
display: grid;
min-width: 0;
@@ -478,6 +496,23 @@ button:disabled {
padding: 18px;
}
.admin-detail-panel {
display: grid;
width: min(100%, 760px);
max-height: min(90dvh, 760px);
gap: 16px;
overflow: auto;
border: 1px solid #d8e2e8;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24);
padding: 18px;
}
.admin-detail-list .admin-code-block {
max-height: 280px;
}
.admin-confirm-warning {
border: 1px solid #efc894;
border-radius: 8px;
@@ -602,6 +637,24 @@ button:disabled {
min-width: 360px;
}
.admin-table-wide {
min-width: 1180px;
}
.admin-json-preview {
max-width: 360px;
max-height: 160px;
margin: 0;
overflow: auto;
color: #2f4550;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-status {
display: inline-flex;
max-width: 460px;
@@ -757,7 +810,8 @@ button:disabled {
.admin-overview-grid,
.admin-two-column,
.admin-two-column-wide,
.admin-form-row {
.admin-form-row,
.admin-filter-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,110 @@
# 百梦线下展会易拉宝设计记录 2026-05-07
## 1. 目标
为百梦线下展会制作一张纵向易拉宝广告展板,用于在展位现场快速传达:
1. 产品名称:百梦。
2. 产品愿景百梦AI团队致力于打造AI互动内容UGC平台。
3. 产品slogan每个人都可以在10分钟内轻松创作出一款精品互动作品。
4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。
5. 关键技术Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。
6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。
## 2. 视觉方向
本次延续 `BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md` 中最新收敛的气泡共创方向:
- 参考 logo`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/baimeng-bubble-04-07-refine-01-04-flat-rainbow-band-core.png`
- 主视觉语义轻盈气泡、很多创意、UGC共创、作品改造与分享。
- 色彩:暖白、珊瑚粉、薰衣草紫、天蓝、薄荷青、少量金色光感。
- 展会阅读策略远看先读产品名与slogan近看再读产品心智与关键技术。
## 3. 文案层级
最终展板文案压缩为四层。
第一层:品牌识别
```text
百梦
AI互动内容UGC平台
把想玩的世界,亲手做出来
```
第二层远读slogan
```text
每个人都可以在10分钟内
轻松创作出一款
精品互动作品
```
第三层:产品特点
```text
10分钟成品级创作
玩过就能改造发布
创意到作品闭环
```
并拆成三张近读卡:
```text
创作从一句灵感开始AI帮助完成剧本、角色、场景、系统与视觉草稿。
游玩:作品不是静态文本,而是可进入、可推进、可演出的互动体验。
改造:玩到不满意的作品,可以快速改成自己喜欢的版本并再次发布。
```
第四层:产品心智与关键技术
```text
当用户找不到想玩的游戏 -> 来百梦做给自己玩
当用户玩到了不好玩的游戏 -> 快速改成自己喜欢玩的
当用户在平台外玩到了不满意的游戏 -> 来百梦做成自己满意的
什么游戏最好玩? -> 来百梦玩自己做的游戏最好玩
```
关键技术压缩为:
```text
基于 Harness Engineering 理论,将专家知识融入 AI 创作工具与 AI 原生游戏框架。
AI创作工具
通过多Agent调度算法把策划、美术SOP与专家知识融入模板框架提升剧本类、数值类、系统类、角色设计、场景设计、CG设计等垂类任务的完成率和效率。
AI原生游戏框架
通过系统化约束模型输入输出,把实时剧本创作和游戏设计专家知识内嵌在规则中,提升实时剧情、数值对齐、画面对齐、任务与物品生成质量。
```
## 4. 生成方式
主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成:
```text
model: gpt-image-2
size: 1536x3840
reference image: 百梦气泡共创logo方向图
output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png
```
因为图片模型直接生成中文长文案存在错字风险最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成:
```text
tmp/imagegen/generate-baimeng-rollup-background.mjs
tmp/imagegen/render-baimeng-rollup.py
```
最终输出:
```text
output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn.png
output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png
```
## 5. 后续改版建议
1. 若印刷厂提供具体尺寸和出血要求,优先在 `render-baimeng-rollup.py` 中调整画布比例、边距和安全区。
2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。
3. 若展会现场观众偏投资人或B端合作方可以把“产品心智”段压缩放大“关键技术”与平台愿景。
4. 若观众偏玩家或普通创作者可以把“关键技术”段压缩放大“10分钟创作、玩过就改、发布分享”的闭环。

View File

@@ -207,6 +207,26 @@
- `01-ring-three-bubbles`:识别直接,但工具 icon 感略强。
- `04-breath-origin`:梦感更强,但现实吹泡泡行为弱。
## 12. 吹泡泡主标再收敛版补充
在“方向正确,再来一些”的基础上,本轮继续压缩泡泡棒方向,目标是减少玩具感,让它更像成熟产品主标。
本轮生成 prompt
`tmp/imagegen/baimeng_logo_bubble_refine_batch12_prompts.jsonl`
本轮输出目录:
`output/imagegen/baimeng-logo-bubble-refine-batch12/`
当前最值得继续推进的是:
- `02-simple-action-mark`:泡泡棒行为明确,结构干净,比上一批更接地气,也不太幼稚。
- `06-one-plus-two-bubbles`:更抽象、更高级,但现实吹泡泡行为感弱一些。
- `08-final-simple-bubbles`:适合 App icon但聊天气泡联想较强需弱化社交聊天框尾巴。
不建议推进 `03/04/07`,它们出现了不稳定文字或方案式字标;`01` 容易被误认为放大镜。
批量生成 prompt 已放在:
`tmp/imagegen/baimeng_logo_gpt_image_2_prompts.jsonl`
@@ -233,3 +253,35 @@ python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.p
APIMART_BASE_URL=https://api.apimart.ai/v1
APIMART_API_KEY=...
```
## 13. 04/07 气泡方向单独优化补充
在用户反馈“更喜欢 04 和 07”后本轮不再横向发散其它生活物件而是只围绕两条已被接受的视觉方向做收敛
- 继承 `04` 的中心大泡泡:保留多条虹彩色带组成的饱满主视觉,但压低小泡泡的高光、阴影和玻璃拟物感。
- 继承 `07` 的色彩和轻轻吹泡泡行为:减少元素数量,确保整体居中,避免一串气泡散向右上角。
- 明确排除 `03/05/08` 中容易出现的聊天软件联想,不使用聊天尾巴、对话框轮廓、碎小装饰点和星形元素。
本轮生成 prompt
`tmp/imagegen/baimeng_logo_bubble_04_07_refine_batch13_prompts.jsonl`
本轮输出目录:
`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/`
联系表:
`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/baimeng-bubble-04-07-refine-batch13-contact-sheet.png`
当前最值得继续推进的是:
- `01-04-flat-rainbow-band-core`:保留了 04 的彩虹色带饱满度,同时只留下一个小辅助泡泡,聊天气泡联想弱,适合作为“梦 / 很多 / 共创”的品牌主标继续精修。
- `02-04-single-full-bubble-ring`:最克制、最像可注册主符号,完全去掉碎元素;缺点是“吹泡泡行为”和 UGC 共创感比其它方案弱,可作为极简品牌基线。
- `08-07-rainbow-breath-symbol`:较好融合了 04 的虹彩大环与 07 的吹泡泡动作,亲和度高;后续需要继续压缩右上两个泡泡的体量,避免重心偏右。
不建议优先推进:
- `03-04-overlap-two-bubbles-brand`:结构清楚,但小环容易变成独立图标,整体略硬。
- `04-04-rainbow-bubble-wand-minimal`:泡泡棒语义明确,但下方手柄过于具象,容易像工具图标。
- `05/06/07`:色彩和亲和力可参考,但泡泡簇仍比 01/02/08 更接近装饰插画,产品主标凝聚力不足。

View File

@@ -15,6 +15,7 @@
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
- [BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md](./BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md):百梦线下展会易拉宝广告展板的文案层级、视觉方向与 gpt-image-2 生成记录。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。

View File

@@ -0,0 +1,61 @@
# 新手引导流程 PRD
## 1. 目标
引导未登录且首次访问产品的用户快速体验从输入想法、AI 生成拼图游戏、完成拼图、注册或登录保留作品,到进入产品首页的创作闭环。
## 2. 触发条件
1. 用户处于未登录状态。
2. 用户首次访问产品。
## 3. 流程
1. 用户首次打开产品。
2. 页面浮现文字:`待定待定待定`
3. 文字下方展示文字输入框。
4. 输入框提示文字:`把你的梦讲给我听吧`
5. 用户输入内容后,点击生成按钮。
6. 系统拉起拼图游戏创作机制。
7. 系统根据用户输入内容生成一个仅包含 1 关的拼图游戏。
8. 生成完成后,页面浮现文字:`待定待定待定`
9. 用户进入当前生成的拼图游戏。
10. 用户完成该拼图游戏的第 1 关。
11. 页面浮现文字:`只差一步,就可以永久保留你的梦`
12. 文字下方展示注册账号/登录模块。
13. 用户完成注册或登录。
14. 系统进入产品首页。
## 4. 文案
| 位置 | 文案 |
| --- | --- |
| 首次启动浮现文案 | `待定待定待定` |
| 输入框提示文字 | `把你的梦讲给我听吧` |
| 生成完成浮现文案 | `待定待定待定` |
| 完成拼图后的注册/登录引导文案 | `只差一步,就可以永久保留你的梦` |
## 5. 范围边界
1. 不增加跳过入口。
2. 不定义额外功能说明文案。
3. 不扩展拼图为多关。
4. 不调整注册/登录后的去向,当前进入产品首页。
5. 不新增未确认的 UI 动画、样式、奖励、埋点或保存策略。
## 6. 验收标准
1. 未登录首次访问产品时,进入新手引导首屏。
2. 首屏展示确认文案、输入框和生成按钮。
3. 用户输入内容并点击生成后,系统生成 1 关拼图。
4. 生成完成后,用户可以进入该拼图并完成第 1 关。
5. 第 1 关完成后,页面展示注册/登录引导文案和登录模块。
6. 用户完成注册或登录后,进入产品首页。
## 7. 落地接口与状态
1. 首次访问判定由前端本地状态承载,未登录用户首次访问平台首页时展示;标记键为 `genarrative.puzzle-onboarding.first-visit.v1`
2. 临时生成入口为 `POST /api/runtime/puzzle/onboarding/generate`,不要求登录,只返回本次新手引导使用的 1 关拼图作品摘要与关卡数据。
3. 登录后保存入口为 `POST /api/runtime/puzzle/onboarding/save`,要求登录;服务端为当前用户创建拼图 agent session并把临时 1 关拼图保存为当前用户作品草稿。
4. 新手引导游玩阶段复用现有本地拼图运行时,不新增 SpacetimeDB 表、reducer 或运行时真相。
5. 保存完成后清空新手引导临时态,刷新拼图作品架,并回到产品首页。

View File

@@ -0,0 +1,170 @@
# 后台埋点数据明细与 Excel 导出方案
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 在百梦后台新增“埋点数据”页,展示每条埋点原始事件的详细字段,并支持导出为 Excel 可直接打开的表格文件。
**Architecture:** 后端继续由 `api-server` 作为后台 BFF经 SpacetimeDB HTTP SQL 只读查询 `tracking_event`,不改变表结构和 reducer。前端在 `apps/admin-web` 中新增独立路由与页面,页面渲染后端返回的原始明细,并在浏览器侧导出 Excel 兼容的 `.xls` HTML 表格,避免新增依赖。
**Tech Stack:** Rust Axum、SpacetimeDB HTTP SQL、shared-contracts、React 19、TypeScript、Vite。
---
## 范围
本次只做后台只读能力:
- 展示 `tracking_event` 原始事件明细。
- 每条埋点展示:事件 ID、Event Key、事件名称、Scope、Scope ID、Day Key、用户 ID、作品拥有者、Profile ID、模块、metadata、发生时间。
- 支持按 Event Key、用户 ID、Scope Kind、Scope ID 筛选。
- 支持导出当前筛选结果为 Excel 可打开文件。
不做:
- 不新增或修改 SpacetimeDB 表结构。
- 不在后台写入或删除埋点。
- 不把埋点聚合口径下沉到前端计算。
## 后端契约
新增接口:
```text
GET /admin/api/tracking/events?eventKey=&userId=&scopeKind=&scopeId=&limit=
```
鉴权:复用后台 `require_admin_auth`
返回:
```json
{
"entries": [
{
"eventId": "daily-login:user:xxx:123",
"eventKey": "daily_login",
"eventTitle": "每日登录",
"scopeKind": "user",
"scopeId": "xxx",
"dayKey": 20580,
"userId": "xxx",
"ownerUserId": null,
"profileId": null,
"moduleKey": "profile",
"metadataJson": "{}",
"occurredAt": "2026-05-07T00:00:00Z"
}
]
}
```
后端实现要点:
1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。
2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。
3. SQL 只读 `tracking_event`,固定白名单列;由于 SpacetimeDB 2.2 HTTP SQL 不支持 `ORDER BY`,后端取回默认 200 / 最大 1000 条后在 API 层按 `occurred_at` 倒序排序。
4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。
5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`
## 前端页面
新增路由:`#tracking`,导航标题为“埋点数据”。
页面能力:
1. 顶部筛选区Event Key、用户 ID、Scope Kind、Scope ID、刷新、导出 Excel。
2. 列表区:移动端可横向滚动,桌面端表格展示。
3. 详情区:每行有“详情”按钮,弹出独立面板展示完整字段与格式化后的 metadata JSON。
4. 导出:导出当前页面已加载结果,文件名形如 `tracking-events-2026-05-07.xls`
导出实现:
- 使用 HTML table + Excel MIME`application/vnd.ms-excel;charset=utf-8`
- 文件扩展名使用 `.xls`Excel/WPS 可直接打开。
- 所有单元格做 HTML 转义。
- metadata 保留原始 JSON 文本,便于运营继续筛选。
## 验收命令
```bash
npm run check:encoding
npm run admin-web:typecheck
cargo test -p shared-contracts -p api-server admin_tracking -- --nocapture
```
如后端接口改动较大,再补充:
```bash
npm run api-server
curl http://127.0.0.1:<port>/healthz
```
## 实施任务
### Task 1: 补充 shared-contracts 后台埋点 DTO
**Files:**
- Modify: `server-rs/crates/shared-contracts/src/admin.rs`
**Steps:**
1. 新增 `AdminTrackingEventListQuery`
2. 新增 `AdminTrackingEventEntryPayload`
3. 新增 `AdminTrackingEventListResponse`
4. 为 DTO 添加中文注释。
### Task 2: 增加后端后台埋点查询接口
**Files:**
- Modify: `server-rs/crates/api-server/src/admin.rs`
- Modify: `server-rs/crates/api-server/src/app.rs`
**Steps:**
1.`admin.rs` 新增 query 解析与 SQL 构造。
2. 复用 SpacetimeDB HTTP SQL 调用风格读取 rows。
3. 新增 `admin_list_tracking_events` handler。
4.`app.rs` 挂载 `/admin/api/tracking/events`
5. 添加单元测试覆盖 SQL 字符串转义、limit clamp、SQL 响应解析。
### Task 3: 增加前端 API 类型与客户端方法
**Files:**
- Modify: `apps/admin-web/src/api/adminApiTypes.ts`
- Modify: `apps/admin-web/src/api/adminApiClient.ts`
**Steps:**
1. 新增埋点 entry/list/query 类型。
2. 新增 `listAdminTrackingEvents(token, query)`
3. 使用 `URLSearchParams` 拼接非空查询字段。
### Task 4: 新增后台埋点数据页面
**Files:**
- Create: `apps/admin-web/src/pages/AdminTrackingEventsPage.tsx`
- Modify: `apps/admin-web/src/styles/admin.css`
**Steps:**
1. 实现筛选、刷新、错误状态。
2. 实现明细表格。
3. 实现独立详情面板。
4. 实现 Excel `.xls` 导出。
5. 保持 UI 简洁,不添加说明类大段文案。
### Task 5: 接入后台路由与导航
**Files:**
- Modify: `apps/admin-web/src/app/adminRoutes.ts`
- Modify: `apps/admin-web/src/app/AdminShell.tsx`
- Modify: `apps/admin-web/src/app/AdminApp.tsx`
**Steps:**
1. 增加 `tracking` 路由。
2. 导航增加图标。
3. `AdminApp` 渲染新页面。
### Task 6: 验证并提交
**Steps:**
1. 运行 `npm run check:encoding`
2. 运行 `npm run admin-web:typecheck`
3. 运行后端相关 cargo test。
4. 修复问题后提交并推送当前分支。

View File

@@ -0,0 +1,69 @@
# 短信验证码阿里云时间戳格式修复2026-05-07
## 背景
使用阿里云短信验证码真实 provider 发送验证码时,接口返回:
```text
短信验证码发送失败Specified time stamp or date value is not well formatted.
```
该错误来自阿里云 OpenAPI 网关对签名请求头 `x-acs-date` 的格式校验。
## 根因
`server-rs/crates/platform-auth/src/lib.rs` 中阿里云 ACS3 签名逻辑会构造 `x-acs-date` 请求头。
原实现使用 `time::format_description::well_known::Rfc3339`,当 `OffsetDateTime::now_utc()` 带纳秒时会生成形如:
```text
2026-05-07T14:23:59.364767Z
```
阿里云 ACS3 签名要求 `x-acs-date` 使用不带小数秒的 UTC ISO 8601 格式:
```text
yyyy-MM-dd'T'HH:mm:ss'Z'
```
即:
```text
2026-05-07T14:23:59Z
```
带小数秒的时间戳会被阿里云网关判定为格式非法,从而返回 `Specified time stamp or date value is not well formatted.`
## 修复方案
`current_aliyun_timestamp()` 改为手动输出不带小数秒的 UTC ISO 8601 格式:
```text
yyyy-MM-dd'T'HH:mm:ss'Z'
```
并新增单元测试,确保:
- 长度等于 `2026-05-07T12:34:56Z`
- 固定位置包含 `-``T``:``Z`
- 不包含小数点;
- 除固定分隔符外均为数字。
## 影响范围
- 仅影响阿里云短信验证码 provider 的请求签名头 `x-acs-date`
- 不改动短信模板、签名、验证码业务参数。
- 不改动 mock 短信 provider。
- 不涉及前端接口契约变化。
## 验收
执行:
```bash
cd server-rs
cargo test -p platform-auth aliyun -- --nocapture
cargo fmt -p platform-auth --check
```
预期:相关测试通过,格式检查通过。

View File

@@ -649,6 +649,8 @@ src/components/match3d-runtime/
1. 名称:`抓大鹅`
2. 子标题:`经典消除玩法`
3. `src/config/newWorkEntryConfig.ts``match3d` 必须保持 `visible: true``open: true`,平台首屏卡带和创作类型弹层都从该配置派生,不允许只保留路由能力却隐藏创作入口。
4. 入口点击后进入 `match3d-agent-workspace`,对应前端路径为 `/creation/match3d/agent`,并通过 `/api/creation/match3d/sessions` 创建正式 Agent 会话;如果公开广场读取失败,只降级广场列表,不能阻断或隐藏抓大鹅创作入口。
## 11.4 运行态 UI

View File

@@ -195,3 +195,58 @@ cannon-es
4. 托盘仍使用共享 `WebGLRenderer`,继续按当前 `visualKey` 和尺寸关系生成同款模型;不得新增每格独立 renderer。
5. 托盘缩放不能继续只按本局最大模型统一压缩所有物体;小尺寸模型需要保留最低可读显示尺寸,但仍不能改动场内真实尺寸、碰撞尺寸和后端权威尺寸。
6. 备选栏单格高度可大于宽度,优先保证局内 3D 预览的识别面积;不得为了适配旧正方形格子把模型再次压小。
## 16. 中心场地隐藏纵深与动态上顶
2026-05-05 针对中心场地高数量局面穿模严重、消除后中下层物体长期陷在深处的问题,追加隐藏纵深与动态上顶表现修正。
编码口径:
1. 该纵深只存在于 3D 物理表现层,不修改锅体图案、锅壁模型、托盘表现、后端快照、点击权威判定、消除和胜负规则。
2. 物体生成高度不再使用固定极小层级步长,而是按本局总物体数计算一个隐藏初始纵深;物体总量越大,初始逻辑纵深越深,用来减少大量放大后模型被挤进同一高度区间导致的穿模。
3. 当前剩余场内物体数会动态缩短可用纵深;随着玩家持续消除,下层物体的目标高度逐步上移,表现为中下层物体陆续向上顶到表面层。
4. 动态上顶只通过向上托举力和目标高度调整完成,不增加中心引力,不修改水平约束半径,不改变碰撞体尺寸倍率。
5. 表面层高度保持稳定,避免越消除越显得物体掉进深处或视觉尺寸异常变小。
## 17. 高数量局面物理稳定与动态锅容量
2026-05-06 继续按方案 C 和方案 D 优化 `clearCount=100` 等高数量局面的稳定性。
方案 C 编码口径:
1. 只调整 3D 表现层的物理稳定参数,包括求解迭代次数、接触摩擦、接触弹性、线性阻尼、角阻尼、睡眠阈值和速度上限。
2. 物体数量越大,物理世界越偏向高摩擦、低弹性和更强阻尼,减少大量物体同时生成后的持续弹跳、穿插和边界挤压。
3. 速度保护只限制极端水平速度和垂直速度,不改物体位置生成规则、点击判定、备选栏、消除和胜负规则。
方案 D 编码口径:
1. 隐藏锅容量的纵深按本局总物体数,也就是用户配置的消除次数乘 `3` 后动态计算;消除次数越大,锅内容量纵深越深。
2. 动态纵深只影响 3D 物理层的生成高度、目标层高度和消除后的上顶回补;锅底、锅壁、锅沿和 DOM 场地外观不随纵深变化。
3. 高数量局面需要降低单层容量,让更多物体分散到纵向层级中,避免 `300` 个物体被压进少量高度层。
4. 随着消除进度推进,当前可用纵深继续按剩余物体数收缩,确保下层物体逐步向表面回补,保持中心场地表层稳定可见。
5. 本节不改变中心引力默认值,不改水平活动半径,不改碰撞体与视觉模型的尺寸一致性规则。
## 18. 原型入场节奏与创建限流
2026-05-07 根据原型视频补充创建过程优化。原型不是在同一帧把全部物体摆进容器,而是先短暂空场,再用连续小批量把物体投放到容器中,批与批之间留出自然沉降时间,最后再进入可操作局面。
编码口径:
1. 该优化只作用于前端 3D 表现层的物理 body 创建节奏,不改变后端快照、消除目标数量、点击权威判定、备选栏、三消和胜负规则。
2. `totalItemCount < 30` 时保留较快创建节奏;`30 <= totalItemCount <= 50` 进入中速波次投放,降低每波数量并增加波次沉降窗口,避免最后一层物体压进尚未稳定的表层堆叠。
3. `totalItemCount > 50` 后进入更强限流投放,单帧创建数量下降,避免同一帧把过多碰撞体塞入物理世界。
4. 随着总物体数增加,投放初始等待、层级间隔和同层错峰间隔都要逐步变长,模拟原型中“持续落入、短暂沉降、继续补入”的节奏。
5. `clearCount=100` 对应 `300` 个物体时,投放节奏应接近连续数秒完成,而不是在一秒左右完成全量创建。
6. 该节不允许通过缩小碰撞体、扩大锅半径、开启中心引力或修改模型尺寸来掩盖穿模;如果后续仍需调整,只继续围绕创建节拍和物理沉降窗口处理。
## 19. 生成高度避让已有堆叠
2026-05-07 继续按方案 2 优化 `30` 件左右局面最后一层或最后一波物体仍会穿进已有堆叠的问题。
编码口径:
1. 该优化只作用于前端 3D 表现层的新物体创建高度,不改变后端快照、物品数量、模型尺寸、碰撞体尺寸、锅半径、点击判定、备选栏、三消和胜负规则。
2. 新物体进入物理世界前,先根据当前同一水平区域附近已有物体的碰撞体顶部高度,计算一个不低于原计划高度的生成高度。
3. 只有水平外接半径发生重叠的已有物体会影响本次生成高度;远处物体不能把新物体整体抬高,避免破坏原有随机洒落和分层节奏。
4. 该避让只解决“直接创建在已有模型内部”的初始穿插,后续沉降、翻滚、堆叠仍交给 cannon-es 物理模拟。
5. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。

View File

@@ -104,9 +104,11 @@
1. 发送验证码调用 `SendSmsVerifyCode`
2. 校验验证码调用 `CheckSmsVerifyCode`
3. 使用阿里云 RPC 签名口径:
- `SignatureMethod=HMAC-SHA1`
- `SignatureVersion=1.0`
3. 使用阿里云 OpenAPI V3 请求头签名口径:
- `Authorization: ACS3-HMAC-SHA256 ...`
- `x-acs-action`
- `x-acs-version`
- `x-acs-content-sha256`
4. 当前仍只支持中国大陆手机号。
## 7. 状态与快照

View File

@@ -4,6 +4,7 @@
## 文档列表
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
- [API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md](./API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md):冻结 api-server 外部服务配置边界,公共服务 URL 可保留代码默认值,非公共模型名和私有网关 URL 统一通过环境变量注入。
- [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。
- [SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md](./SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md):记录方洞挑战结果页图片槽位局部生成、洞口图历史素材、运行态拖拽与点击投放交互的修正口径。

View File

@@ -0,0 +1,55 @@
# Rust workspace 依赖集中配置记录
日期:`2026-05-07`
## 1. 背景
`server-rs` workspace 已经包含 `api-server``spacetime-module``spacetime-client`、多个 `module-*` 领域 crate、`platform-*` 适配 crate 和共享 crate。随着 DDD 收口推进,成员 `Cargo.toml` 中重复散写了第三方 crate 版本和本地 path 依赖,后续升级 `serde``reqwest``tokio``time`、SpacetimeDB SDK 或内部 crate 路径时容易出现漂移。
本次只做 Cargo 配置收敛不改变业务代码、表结构、reducer/procedure 签名、HTTP contract 或前端绑定。
## 2. 配置规则
1. 共享第三方依赖版本统一维护在 `server-rs/Cargo.toml``[workspace.dependencies]`
2. workspace 内部 crate 的 `path` 也统一维护在根 `server-rs/Cargo.toml`
3. 成员 crate 默认使用 `{ workspace = true }` 继承依赖。
4. 成员 crate 只保留自身需要表达的差异,例如 `features``optional = true` 或 target-specific dependency。
5. 需要关闭 default features 的依赖,应优先在 workspace 根依赖中声明;成员 crate 不再重复覆盖同一项。
6. `module-assets` 这类有默认服务端 feature 的领域 crate在 workspace 根内按 `default-features = false` 维护;需要服务端 OSS/HTTP 能力的 adapter crate 显式启用 `features = ["server-service"]`
## 3. 本次收敛范围
已上提到 workspace 根的依赖包括:
1. 本地路径依赖:`module-*``platform-*``shared-*``spacetime-client`
2. 常用第三方依赖:`serde``serde_json``serde_urlencoded``reqwest``tokio``time``tracing``base64``hmac``sha2``uuid``url` 等。
3. SpacetimeDB 相关依赖:`spacetimedb``spacetimedb-sdk``spacetimedb-lib`
`spacetimedb-lib` 在 workspace 根统一关闭 default features`spacetime-module` 只继承并补充 `features = ["serde"]`。这样避免成员 crate 尝试覆盖 workspace default-feature 设定导致 manifest 解析失败。
阿里云 OSS 相关签名不再依赖不推荐的 `sha1` crate统一使用 `sha2::Sha256`
1. 浏览器直传 ticket 使用 OSS V4 表单签名字段:`x-oss-signature-version=OSS4-HMAC-SHA256``x-oss-credential``x-oss-date``x-oss-signature`
2. 服务端 OSS 读写请求和测试辅助签名统一使用 `OSS4-HMAC-SHA256` Authorization。
3. 阿里云短信 OpenAPI 请求统一使用 `ACS3-HMAC-SHA256` 请求头签名,不再在表单中传旧 `SignatureMethod=HMAC-SHA1` / `SignatureVersion=1.0`
## 4. 不在本次范围
1. 不新增或删除 crate。
2. 不修改 `server-rs` workspace `members` / `default-members` 语义。
3. 不修改 SpacetimeDB 表、reducer、procedure、migration 白名单或生成绑定。
4. 不改变 `module-*` 的 DDD 依赖方向。
## 5. 验收口径
配置改动后至少执行:
```powershell
cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps
cargo check -p api-server --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
npm.cmd run check:server-rs-ddd
npm.cmd run check:encoding -- docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md docs/technical/README.md server-rs/README.md .hermes/shared-memory/decision-log.md .hermes/shared-memory/project-overview.md
```
若仅改 Cargo 依赖配置且未触碰 API smoke 相关代码,不强制启动 `npm run api-server`;若后续改动同时涉及 API 路由、SpacetimeDB facade 或运行时行为,仍按 `AGENTS.md` 和 DDD 文档执行后端 smoke。

View File

@@ -1,6 +1,6 @@
pipeline {
agent {
label 'built-in && windows'
label 'windows'
}
options {
@@ -16,7 +16,7 @@ pipeline {
CARGO_INCREMENTAL = '0'
RUSTC_WRAPPER = 'sccache'
SCCACHE_DIR = '${env.USERPROFILE}\\.cache\\sccache-stdb-module'
SCCACHE_CACHE_SIZE = '30G'
SCCACHE_CACHE_SIZE = '30G'o
}
parameters {

View File

@@ -0,0 +1,16 @@
import type { PuzzleDraftLevel } from './puzzleAgentDraft';
import type { PuzzleWorkSummary } from './puzzleWorkSummary';
export interface PuzzleOnboardingGenerateRequest {
promptText: string;
}
export interface PuzzleOnboardingGenerateResponse {
item: PuzzleWorkSummary;
level: PuzzleDraftLevel;
}
export interface PuzzleOnboardingSaveRequest {
promptText: string;
item: PuzzleWorkSummary;
}

View File

@@ -10,6 +10,7 @@ export * from './contracts/match3dWorks';
export * from './contracts/puzzleAgentActions';
export * from './contracts/puzzleAgentDraft';
export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleOnboarding';
export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary';

View File

@@ -1,3 +1,6 @@
[build]
rustc-wrapper = "sccache"
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

65
server-rs/Cargo.lock generated
View File

@@ -75,7 +75,6 @@ dependencies = [
"dotenvy",
"hmac",
"http-body-util",
"httpdate",
"image",
"module-ai",
"module-assets",
@@ -98,7 +97,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha1",
"sha2",
"shared-contracts",
"shared-kernel",
"shared-logging",
@@ -1872,14 +1871,13 @@ name = "platform-auth"
version = "0.1.0"
dependencies = [
"argon2",
"base64 0.22.1",
"hmac",
"jsonwebtoken",
"rand_core 0.6.4",
"reqwest",
"serde",
"serde_json",
"sha1",
"serde_urlencoded",
"sha2",
"shared-kernel",
"time",
@@ -1906,11 +1904,10 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"hmac",
"httpdate",
"reqwest",
"serde",
"serde_json",
"sha1",
"sha2",
"time",
"tokio",
]
@@ -2730,9 +2727,9 @@ dependencies = [
[[package]]
name = "spacetimedb"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "591f9068644aab6808e7612a869dedde7eeb26df78027a19bc9dc597cc649678"
checksum = "1306cc3a9ed9c89f43b263614a529357cc53a067e3d06c1cbb485e3b577b118b"
dependencies = [
"anyhow",
"bytemuck",
@@ -2753,9 +2750,9 @@ dependencies = [
[[package]]
name = "spacetimedb-bindings-macro"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f68bf4810d838be622c13efd4cd64e0a9ce8cd340deaa730f0c92caee845f9"
checksum = "51567ec01cd323438a00c134c16f26ffcde5f9dbe6a42a52e54578285bf49d73"
dependencies = [
"heck 0.4.1",
"humantime",
@@ -2767,18 +2764,18 @@ dependencies = [
[[package]]
name = "spacetimedb-bindings-sys"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c2fe9f4124a599c9deae8f8231be3ae5a49bc5b2eef5e04c04b2632cf4cc0b4"
checksum = "3b40fa1bea26664085febe2b4455568c8b47dea2cb0245406b27e30963df2ba1"
dependencies = [
"spacetimedb-primitives",
]
[[package]]
name = "spacetimedb-client-api-messages"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a18c2e145f61ad498f8094a2231e09f8d39a3dde09defa716075dbcb8c7e85"
checksum = "dfc9eeb20a555bad07029cbee4efe3a305cb5c1e40e21a07cbbbbed16a106014"
dependencies = [
"bytes",
"bytestring",
@@ -2798,9 +2795,9 @@ dependencies = [
[[package]]
name = "spacetimedb-data-structures"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4035c17ddbfc8c49a659bd6fb265b0a2a11115d1b4ad1963bccfad75cdfb4b"
checksum = "748fd5850a757823c5b8948065d9e4dc5092968a051aa3f34f170e91d95e493b"
dependencies = [
"ahash",
"crossbeam-queue",
@@ -2813,9 +2810,9 @@ dependencies = [
[[package]]
name = "spacetimedb-lib"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "672c0dd16feced67155a0dee7bd38d30f7725321c8177cb871a21c3d8749ae97"
checksum = "5612611d09d358f535438275d2a0d6a5e2fa56fa583dcfdbeddd623974df1d5e"
dependencies = [
"anyhow",
"bitflags",
@@ -2837,9 +2834,9 @@ dependencies = [
[[package]]
name = "spacetimedb-memory-usage"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c00614eb981354ee6b31661ec47002d3fc274f9d4543279dd6ee8692cdd8266"
checksum = "1c3a0d08fc5d8688a47e3ffcb803275519663b7ea1fba7ad25e608182de4ec6d"
dependencies = [
"decorum",
"ethnum",
@@ -2847,9 +2844,9 @@ dependencies = [
[[package]]
name = "spacetimedb-metrics"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6f7f6b24932505a696b75b7e5e60646ab1d76eeb8d2f95f04948562c965b5e"
checksum = "ca2d647201339aa17ba438a07463e96ed64ba214fb0c182588e262b055efa7f3"
dependencies = [
"arrayvec",
"itertools",
@@ -2859,9 +2856,9 @@ dependencies = [
[[package]]
name = "spacetimedb-primitives"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dba5d7497d54aa8d4254f78a0bef12606bb05e62f8dea8b69abc9b241508e8b7"
checksum = "9b668b51e7318207ae7eebcd4cae0c5d43bf713e7f229ac309ea2614a486ffde"
dependencies = [
"bitflags",
"either",
@@ -2873,18 +2870,18 @@ dependencies = [
[[package]]
name = "spacetimedb-query-builder"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d04c6e41e05273f14405ac6f429477626677d46528b561a509b7b78b45128f30"
checksum = "0186b1a2b3bf25bdd0f2676b61801fd754013ca6a58e1e24cc5148945388bc9d"
dependencies = [
"spacetimedb-lib",
]
[[package]]
name = "spacetimedb-sats"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfde33ec86d80881da8b00c42096bf0382bef8e1bc35e9b6faaa42d77cbf503c"
checksum = "11780ed69f178bf3784b7599da5171450e4b7ac6fd66b79e2e1861c867cef1a6"
dependencies = [
"anyhow",
"arrayvec",
@@ -2915,9 +2912,9 @@ dependencies = [
[[package]]
name = "spacetimedb-schema"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b03b34a38bd39f3f60a0687efafb942355bd9f6026b88a38c7c9ec904e944f1"
checksum = "1e4e9f8aa596e0e7034f0c8b3649d3fa3cc7bde340761519c3a3c60f10ec8888"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -2946,9 +2943,9 @@ dependencies = [
[[package]]
name = "spacetimedb-sdk"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7302851fec72929ffef976125f51971b0ec76be2730e27705a8e544f2ce159"
checksum = "41e82f20034b8aaeaa081871b07895aab45be1f0fc35e114ab64ae8e7e5c1a54"
dependencies = [
"anymap3",
"base64 0.21.7",
@@ -2978,9 +2975,9 @@ dependencies = [
[[package]]
name = "spacetimedb-sql-parser"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cbb9a837ac5f1ddb0cfb745159dea276dcf456244452f5a90684e5184f1f31"
checksum = "ec5c77a2d4e3f42ede59598c56cb81a0fe54fd1974e2707f7140d1d5f41d08a7"
dependencies = [
"derive_more",
"spacetimedb-lib",

View File

@@ -42,8 +42,62 @@ version = "0.1.0"
license = "UNLICENSED"
[workspace.dependencies]
# 本地 workspace crate 路径统一在这里维护,成员 crate 只声明 feature 差异。
module-ai = { path = "crates/module-ai", default-features = false }
module-assets = { path = "crates/module-assets", default-features = false }
module-auth = { path = "crates/module-auth", default-features = false }
module-big-fish = { path = "crates/module-big-fish", default-features = false }
module-combat = { path = "crates/module-combat", default-features = false }
module-custom-world = { path = "crates/module-custom-world", default-features = false }
module-inventory = { path = "crates/module-inventory", default-features = false }
module-match3d = { path = "crates/module-match3d", default-features = false }
module-npc = { path = "crates/module-npc", default-features = false }
module-progression = { path = "crates/module-progression", default-features = false }
module-puzzle = { path = "crates/module-puzzle", default-features = false }
module-quest = { path = "crates/module-quest", default-features = false }
module-runtime = { path = "crates/module-runtime", default-features = false }
module-runtime-item = { path = "crates/module-runtime-item", default-features = false }
module-runtime-story = { path = "crates/module-runtime-story", default-features = false }
module-square-hole = { path = "crates/module-square-hole", default-features = false }
module-story = { path = "crates/module-story", default-features = false }
platform-auth = { path = "crates/platform-auth", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false }
shared-contracts = { path = "crates/shared-contracts", default-features = false }
shared-kernel = { path = "crates/shared-kernel", default-features = false }
shared-logging = { path = "crates/shared-logging", default-features = false }
spacetime-client = { path = "crates/spacetime-client", default-features = false }
argon2 = "0.5"
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
dotenvy = "0.15"
hmac = "0.12"
http-body-util = "0.1"
image = { version = "0.25", default-features = false }
jsonwebtoken = "9"
log = "0.4"
spacetimedb = "2.1.0"
rand_core = "0.6"
reqwest = { version = "0.12", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
sha2 = "0.10"
spacetimedb = "2.2.0"
spacetimedb-sdk = "2.2.0"
spacetimedb-lib = { version = "2.2.0", default-features = false }
time = "0.3"
tokio = "1"
tokio-stream = "0.1"
tower = "0.5"
tower-http = "0.6"
tracing = "0.1"
tracing-subscriber = "0.3"
url = "2"
urlencoding = "2"
uuid = "1"
webp = "0.3"
[profile.dev]
opt-level = 0 # 默认 0有人手滑改 1/2 会慢

View File

@@ -14,7 +14,7 @@
## 2. 当前阶段说明
当前目录已经完成以下三十项初始化:
当前目录已经完成以下三十项初始化:
1. 为新后端预留正式目录并把路径固定到仓库结构中。
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
@@ -53,6 +53,7 @@
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
37. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。
38. 固定 `Cargo.toml` 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`
后续任务会继续在本目录内按顺序补齐:
@@ -109,8 +110,15 @@
4. `spacetime-module` 新增业务入口前先确认是否已有对应上下文目录,禁止继续把大段业务流程堆回 `src/lib.rs`
5. 根目录可执行 `npm run check:server-rs-ddd` 检查第一阶段 DDD 骨架与绝对边界。
## 7. Cargo 依赖配置口径
`2026-05-07` 起,`server-rs` 的依赖版本和 workspace 内部 crate path 统一维护在根 `Cargo.toml``[workspace.dependencies]`,完整记录见 [../docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](../docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md)。
成员 crate 的 `Cargo.toml` 默认使用 `{ workspace = true }` 继承依赖;只在成员 crate 内保留本 crate 的 feature、optional、target-specific dependency 等差异。新增 crate 或新增依赖时,应优先补根 workspace 依赖,再在成员 crate 中继承。
## 5. 关联文档
1. [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
2. [../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)
3. [../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md](../backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md)
4. [../docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](../docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md)

View File

@@ -5,51 +5,50 @@ version.workspace = true
license.workspace = true
[dependencies]
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
dotenvy = "0.15"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
webp = "0.3"
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-auth = { path = "../module-auth" }
module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }
platform-llm = { path = "../platform-llm" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
shared-logging = { path = "../shared-logging" }
spacetime-client = { path = "../spacetime-client" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = "0.1"
time = { version = "0.3", features = ["formatting"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
async-stream = { workspace = true }
axum = { workspace = true }
base64 = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-combat = { workspace = true }
module-custom-world = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
platform-auth = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
spacetime-client = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
sha1 = "0.10"
tower = { version = "0.5", features = ["util"] }
base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
sha2 = { workspace = true }
tower = { workspace = true, features = ["util"] }

View File

@@ -1,11 +1,12 @@
use std::{
collections::BTreeSet,
fs,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
};
use axum::{
Json,
extract::{Extension, Request, State},
extract::{Extension, Query, Request, State},
http::{
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
header::{AUTHORIZATION, CONTENT_TYPE},
@@ -20,6 +21,7 @@ use shared_contracts::admin::{
AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -42,6 +44,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200;
const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000;
#[derive(Clone, Debug)]
pub struct AuthenticatedAdmin {
@@ -153,6 +157,19 @@ pub async fn admin_debug_http(
Ok(json_success_body(Some(&request_context), response))
}
pub async fn admin_list_tracking_events(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Query(query): Query<AdminTrackingEventListQuery>,
) -> Result<Json<Value>, AppError> {
let entries = fetch_admin_tracking_events(&state, query).await?;
Ok(json_success_body(
Some(&request_context),
AdminTrackingEventListResponse { entries },
))
}
pub async fn require_admin_auth(
State(state): State<AppState>,
mut request: Request,
@@ -488,6 +505,290 @@ fn parse_count_value(value: &Value) -> Result<u64, String> {
}
}
async fn fetch_admin_tracking_events(
state: &AppState,
query: AdminTrackingEventListQuery,
) -> Result<Vec<AdminTrackingEventEntryPayload>, AppError> {
let client = Client::new();
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
let database = state.config.spacetime_database.trim();
let token = state
.config
.spacetime_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(load_local_spacetime_cli_token);
let sql = build_admin_tracking_events_sql(&query)
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("埋点数据读取失败:{error}"))
})?;
parse_admin_tracking_events_sql_response(payload).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("埋点数据解析失败:{error}"))
})
}
fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Result<String, String> {
let mut conditions = Vec::new();
if let Some(value) = normalized_non_empty(query.event_key.as_deref()) {
conditions.push(format!("event_key = {}", quote_sql_string(value)));
}
if let Some(value) = normalized_non_empty(query.user_id.as_deref()) {
conditions.push(format!("user_id = {}", quote_sql_string(value)));
}
if let Some(value) = normalized_non_empty(query.scope_kind.as_deref()) {
let scope_kind = normalize_admin_tracking_scope_kind(value)?;
conditions.push(format!("scope_kind = {}", quote_sql_string(scope_kind)));
}
if let Some(value) = normalized_non_empty(query.scope_id.as_deref()) {
conditions.push(format!("scope_id = {}", quote_sql_string(value)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let limit = clamp_admin_tracking_event_limit(query.limit);
Ok(format!(
"SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} LIMIT {limit}"
))
}
fn normalized_non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn load_local_spacetime_cli_token() -> Option<String> {
// 本地开发清库后会通过 `/v1/identity` 重新登录 CLI这里复用 CLI token确保 SQL 可读取 private 表。
let content = fs::read_to_string(".spacetimedb/local/config/cli.toml")
.or_else(|_| fs::read_to_string("server-rs/.spacetimedb/local/config/cli.toml"))
.ok()?;
content.lines().find_map(|line| {
let value = line.trim().strip_prefix("spacetimedb_token = ")?;
Some(value.trim().trim_matches('"').to_string()).filter(|token| !token.is_empty())
})
}
fn quote_sql_string(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn normalize_admin_tracking_scope_kind(value: &str) -> Result<&'static str, String> {
match value.trim().to_ascii_lowercase().as_str() {
"site" => Ok("site"),
"work" => Ok("work"),
"module" => Ok("module"),
"user" => Ok("user"),
_ => Err("scopeKind 必须是 site/work/module/user".to_string()),
}
}
fn clamp_admin_tracking_event_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(ADMIN_TRACKING_EVENT_DEFAULT_LIMIT)
.clamp(1, ADMIN_TRACKING_EVENT_MAX_LIMIT)
}
async fn fetch_spacetime_sql_json(
client: &Client,
server_root: &str,
database: &str,
token: Option<&str>,
sql: &str,
) -> Result<Value, String> {
let mut request = client
.post(format!("{server_root}/v1/database/{database}/sql"))
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.body(sql.to_string());
if let Some(token) = token {
request = request.bearer_auth(token);
}
let response = request
.send()
.await
.map_err(|error| format!("SQL 请求失败:{error}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("HTTP {}{}", status.as_u16(), trim_preview(&body)));
}
response
.json::<Value>()
.await
.map_err(|error| format!("SQL 响应解析失败:{error}"))
}
fn parse_admin_tracking_events_sql_response(
payload: Value,
) -> Result<Vec<AdminTrackingEventEntryPayload>, String> {
let rows = extract_first_sql_rows(payload)?;
rows.iter()
.map(parse_admin_tracking_event_row)
.collect::<Result<Vec<_>, _>>()
.map(|mut entries| {
// SpacetimeDB 2.2 的 HTTP SQL 暂不支持 ORDER BY后台在 API 层按发生时间倒序收口。
entries.sort_by(|left, right| right.occurred_at.cmp(&left.occurred_at));
entries
})
}
fn extract_first_sql_rows(payload: Value) -> Result<Vec<Value>, String> {
let statement = match payload {
Value::Array(statements) => statements
.into_iter()
.next()
.ok_or_else(|| "SQL 结果为空".to_string())?,
Value::Object(statement) => Value::Object(statement),
_ => return Err("SQL 响应格式非法".to_string()),
};
let Value::Object(mut statement) = statement else {
return Err("SQL statement 结果格式非法".to_string());
};
let rows = statement
.remove("rows")
.ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?;
match rows {
Value::Array(rows) => Ok(rows),
_ => Err("SQL rows 字段格式非法".to_string()),
}
}
fn parse_admin_tracking_event_row(row: &Value) -> Result<AdminTrackingEventEntryPayload, String> {
let columns = row.as_array().ok_or_else(|| "埋点行格式非法".to_string())?;
let event_key = required_string_column(columns, 1, "event_key")?;
Ok(AdminTrackingEventEntryPayload {
event_id: required_string_column(columns, 0, "event_id")?,
event_title: admin_tracking_event_title(&event_key).to_string(),
event_key,
scope_kind: tracking_scope_kind_to_string(
columns
.get(2)
.ok_or_else(|| "埋点行缺少 scope_kind".to_string())?,
)
.ok_or_else(|| "埋点行 scope_kind 类型非法".to_string())?,
scope_id: required_string_column(columns, 3, "scope_id")?,
day_key: required_i64_column(columns, 4, "day_key")?,
user_id: optional_string_column(columns, 5),
owner_user_id: optional_string_column(columns, 6),
profile_id: optional_string_column(columns, 7),
module_key: optional_string_column(columns, 8),
metadata_json: required_string_column(columns, 9, "metadata_json")?,
occurred_at: timestamp_to_display_string(
columns
.get(10)
.ok_or_else(|| "埋点行缺少 occurred_at".to_string())?,
)
.ok_or_else(|| "埋点行 occurred_at 不是字符串".to_string())?,
})
}
fn required_string_column(
columns: &[Value],
index: usize,
field_name: &str,
) -> Result<String, String> {
value_to_string(
columns
.get(index)
.ok_or_else(|| format!("埋点行缺少 {field_name}"))?,
)
.ok_or_else(|| format!("埋点行 {field_name} 不是字符串"))
}
fn optional_string_column(columns: &[Value], index: usize) -> Option<String> {
columns.get(index).and_then(value_to_string)
}
fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result<i64, String> {
let value = columns
.get(index)
.ok_or_else(|| format!("埋点行缺少 {field_name}"))?;
match value {
Value::Number(number) => number
.as_i64()
.ok_or_else(|| format!("埋点行 {field_name} 不是整数")),
Value::String(text) => text
.trim()
.parse::<i64>()
.map_err(|error| format!("埋点行 {field_name} 解析失败:{error}")),
_ => Err(format!("埋点行 {field_name} 类型非法")),
}
}
fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
Value::Object(object) => object.get("some").and_then(value_to_string),
Value::Number(number) => Some(number.to_string()),
Value::Bool(value) => Some(value.to_string()),
Value::Array(items) => value_array_to_string(items),
}
}
fn value_array_to_string(items: &[Value]) -> Option<String> {
if items.len() == 2 {
if let Some(index) = items.first().and_then(Value::as_u64) {
if index == 0 {
return items.get(1).and_then(value_to_string);
}
if index == 1 && items.get(1).and_then(Value::as_array).is_some() {
return None;
}
}
}
Some(Value::Array(items.to_vec()).to_string())
}
fn tracking_scope_kind_to_string(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Object(object) => object
.get("tag")
.or_else(|| object.get("variant"))
.or_else(|| object.get("name"))
.and_then(value_to_string),
Value::Array(items) => {
let index = items.first().and_then(Value::as_u64)?;
Some(
match index {
0 => "site",
1 => "work",
2 => "module",
3 => "user",
_ => return Some(Value::Array(items.to_vec()).to_string()),
}
.to_string(),
)
}
_ => value_to_string(value),
}
}
fn timestamp_to_display_string(value: &Value) -> Option<String> {
match value {
Value::Array(items) if items.len() == 1 => items.first().and_then(value_to_string),
_ => value_to_string(value),
}
}
fn admin_tracking_event_title(event_key: &str) -> &str {
match event_key {
"daily_login" => "每日登录",
_ => event_key,
}
}
async fn execute_admin_debug_http(
state: &AppState,
payload: AdminDebugHttpRequest,
@@ -648,12 +949,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
build_body_preview, build_debug_base_url, build_spacetime_schema_url,
is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error,
parse_spacetime_sql_count_response, trim_preview,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name,
normalize_debug_path, normalize_table_count_error,
parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview,
};
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::AdminTrackingEventListQuery;
#[test]
fn normalize_debug_path_rejects_absolute_url() {
@@ -816,6 +1119,136 @@ mod tests {
assert_eq!(count, 3);
}
#[test]
fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() {
let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery {
event_key: Some("daily'login".to_string()),
user_id: Some("user-1".to_string()),
scope_kind: Some("USER".to_string()),
scope_id: Some("scope-1".to_string()),
limit: Some(2000),
})
.expect("tracking sql should build");
assert!(sql.contains("event_key = 'daily''login'"));
assert!(sql.contains("user_id = 'user-1'"));
assert!(sql.contains("scope_kind = 'user'"));
assert!(sql.contains("scope_id = 'scope-1'"));
assert!(!sql.contains("ORDER BY"));
assert!(sql.ends_with("LIMIT 1000"));
}
#[test]
fn clamp_admin_tracking_event_limit_uses_default_and_bounds() {
assert_eq!(clamp_admin_tracking_event_limit(None), 200);
assert_eq!(clamp_admin_tracking_event_limit(Some(0)), 1);
assert_eq!(clamp_admin_tracking_event_limit(Some(1001)), 1000);
}
#[test]
fn parse_admin_tracking_events_sql_response_accepts_statement_array_rows() {
let payload = json!([
{
"rows": [[
"event-1",
"daily_login",
"user",
"user-1",
20580,
{"some": "user-1"},
null,
{"some": "profile-1"},
"profile",
"{\"source\":\"task\"}",
"2026-05-07T00:00:00Z"
]]
}
]);
let entries =
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].event_id, "event-1");
assert_eq!(entries[0].event_title, "每日登录");
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
assert_eq!(entries[0].module_key.as_deref(), Some("profile"));
}
#[test]
fn parse_admin_tracking_events_sql_response_normalizes_sats_values() {
let payload = json!([
{
"rows": [[
"event-1",
"daily_login",
[3, []],
"user-1",
20580,
[0, "user-1"],
[1, []],
[0, "profile-1"],
[0, "profile"],
"{}",
[1778207451731746i64]
]]
}
]);
let entries =
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
assert_eq!(entries[0].scope_kind, "user");
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
assert_eq!(entries[0].owner_user_id, None);
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
assert_eq!(entries[0].module_key.as_deref(), Some("profile"));
assert_eq!(entries[0].occurred_at, "1778207451731746");
}
#[test]
fn parse_admin_tracking_events_sql_response_sorts_by_occurred_at_desc() {
let payload = json!([
{
"rows": [
[
"event-old",
"daily_login",
"user",
"user-1",
20580,
{"some": "user-1"},
null,
{"some": "profile-1"},
"profile",
"{}",
"2026-05-07T00:00:00Z"
],
[
"event-new",
"daily_login",
"user",
"user-1",
20580,
{"some": "user-1"},
null,
{"some": "profile-1"},
"profile",
"{}",
"2026-05-07T01:00:00Z"
]
]
}
]);
let entries =
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
assert_eq!(entries[0].event_id, "event-new");
assert_eq!(entries[1].event_id, "event-old");
}
#[test]
fn build_body_preview_handles_utf8() {
let preview = build_body_preview("后台测试".as_bytes());

View File

@@ -13,7 +13,10 @@ use tower_http::{
use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth},
admin::{
admin_debug_http, admin_list_tracking_events, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
@@ -85,11 +88,12 @@ use crate::{
puzzle::{
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
generate_puzzle_onboarding_work, get_puzzle_agent_session, get_puzzle_gallery_detail,
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work,
save_puzzle_onboarding_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
@@ -168,6 +172,13 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth,
)),
)
.route(
"/admin/api/tracking/events",
get(admin_list_tracking_events).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)
@@ -1003,6 +1014,19 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/onboarding/generate",
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
)),
)
.route(
"/api/runtime/puzzle/onboarding/save",
post(save_puzzle_onboarding_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/works",
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(

View File

@@ -473,26 +473,23 @@ mod tests {
error::Error,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use httpdate::fmt_http_date;
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::new_uuid_simple_string;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
@@ -653,8 +650,13 @@ mod tests {
Value::String("private".to_string())
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
payload["data"]["upload"]["formFields"]["x-oss-signature-version"],
Value::String("OSS4-HMAC-SHA256".to_string())
);
assert!(
payload["data"]["upload"]["formFields"]["x-oss-credential"]
.as_str()
.is_some_and(|value| value.starts_with("test-access-key-id/"))
);
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
@@ -702,7 +704,7 @@ mod tests {
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("OSSAccessKeyId=test-access-key-id"))
.is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256"))
);
}
@@ -1410,13 +1412,27 @@ mod tests {
.oss_access_key_secret
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?;
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!("/{bucket}/{}", object_key.trim_start_matches('/')),
None => format!("/{bucket}/"),
};
let string_to_sign = format!("{}\n\n\n{}\n{}", method.as_str(), date, canonical_resource);
let signature = sign_oss_string(access_key_secret, &string_to_sign)?;
let signed_at = time::OffsetDateTime::now_utc();
let signed_at_text = build_oss_v4_signature_date(signed_at);
let signature_scope = build_oss_v4_signature_scope(endpoint, signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
let payload_hash = "UNSIGNED-PAYLOAD";
let canonical_headers = format!(
"host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n"
);
let additional_headers = "host";
let canonical_request = format!(
"{}\n{}\n\n{}\n{}\n{}",
method.as_str(),
canonical_uri,
canonical_headers,
additional_headers,
payload_hash
);
let string_to_sign =
build_oss_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_oss_v4_content(access_key_secret, &signature_scope, &string_to_sign)?;
let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => build_object_url(config, object_key)?,
None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?,
@@ -1424,18 +1440,147 @@ mod tests {
let response = client
.request(method, target_url)
.header("Date", date)
.header("Authorization", format!("OSS {access_key_id}:{signature}"))
.header("x-oss-content-sha256", payload_hash)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"OSS4-HMAC-SHA256 Credential={access_key_id}/{signature_scope},AdditionalHeaders={additional_headers},Signature={signature}"
),
)
.send()
.await?;
Ok(response)
}
fn sign_oss_string(secret: &str, content: &str) -> Result<String, Box<dyn Error>> {
let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?;
fn build_oss_v4_signature_scope(
endpoint: &str,
signed_at: time::OffsetDateTime,
) -> Result<String, Box<dyn Error>> {
let date = signed_at.date().to_string().replace('-', "");
let region = endpoint
.trim()
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.ok_or_else(|| std::io::Error::other("OSS endpoint 无法解析 region"))?;
Ok(format!("{date}/{region}/oss/aliyun_v4_request"))
}
fn build_oss_v4_signature_date(signed_at: time::OffsetDateTime) -> String {
let date = signed_at.date().to_string().replace('-', "");
let time = signed_at
.time()
.to_string()
.split('.')
.next()
.unwrap_or("00:00:00")
.replace(':', "");
debug_assert_eq!(time.len(), 6);
format!("{date}T{time}Z")
}
fn build_oss_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!(
"/{}/{}",
encode_oss_url_query_value(bucket),
encode_oss_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_oss_url_query_value(bucket)),
}
}
fn build_oss_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"OSS4-HMAC-SHA256\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
fn sign_oss_v4_content(
secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, Box<dyn Error>> {
let signing_key = build_oss_v4_signing_key(secret, signature_scope)?;
let mut signer = HmacSha256::new_from_slice(&signing_key)?;
signer.update(content.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
Ok(hex_lower(&signer.finalize().into_bytes()))
}
fn build_oss_v4_signing_key(
secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, Box<dyn Error>> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少日期"))?;
let region = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 region"))?;
let service = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 service"))?;
let request = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 request"))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let mut signer = HmacSha256::new_from_slice(key)?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn encode_oss_url_path(path: &str) -> String {
path.split('/')
.map(encode_oss_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_oss_url_query_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
_ => {
use std::fmt::Write as _;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
}
encoded
}
fn ensure_success_status(status: u16, message: &str) -> Result<(), Box<dyn Error>> {

View File

@@ -72,12 +72,33 @@ mod work_author;
use shared_logging::init_tracing;
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tracing::info;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
fn main() -> Result<(), std::io::Error> {
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
std::thread::Builder::new()
.name("api-server-bootstrap".to_string())
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.spawn(run_api_server_with_runtime)?
.join()
.map_err(|_| std::io::Error::other("api-server 启动线程异常退出"))?
}
fn run_api_server_with_runtime() -> Result<(), std::io::Error> {
TokioRuntimeBuilder::new_multi_thread()
.enable_all()
.thread_name("api-server-worker")
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.build()?
.block_on(run_api_server())
}
async fn run_api_server() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");

View File

@@ -45,7 +45,8 @@ use shared_contracts::{
UsePuzzleRuntimePropRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse,
PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
},
};
@@ -157,6 +158,222 @@ pub async fn create_puzzle_agent_session(
))
}
pub async fn generate_puzzle_onboarding_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let prompt_text = payload.prompt_text.trim().to_string();
ensure_non_empty(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
&prompt_text,
"promptText",
)?;
let now = current_utc_micros();
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
let level_name = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
let tags = generate_puzzle_work_tags(&state, level_name.as_str(), prompt_text.as_str()).await;
let candidates = generate_puzzle_image_candidates(
&state,
"onboarding-guest",
session_id.as_str(),
level_name.as_str(),
prompt_text.as_str(),
None,
Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2),
1,
0,
)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_generation_endpoint_error(error),
)
})?;
let selected = candidates.first().cloned().ok_or_else(|| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "新手引导拼图图片生成结果为空",
})),
)
})?;
let level = PuzzleDraftLevelRecord {
level_id: "onboarding-level-1".to_string(),
level_name: level_name.clone(),
picture_description: prompt_text.clone(),
candidates,
selected_candidate_id: Some(selected.candidate_id.clone()),
cover_image_src: Some(selected.image_src.clone()),
cover_asset_id: Some(selected.asset_id.clone()),
generation_status: "ready".to_string(),
};
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
level_name.as_str(),
level.picture_description.as_str(),
));
let item = PuzzleWorkProfileRecord {
work_id: format!("onboarding-work-{now}"),
profile_id: format!("onboarding-profile-{now}"),
owner_user_id: "onboarding-guest".to_string(),
source_session_id: None,
author_display_name: "百梦主".to_string(),
work_title: level_name.clone(),
work_description: prompt_text.clone(),
level_name,
summary: prompt_text,
theme_tags: tags,
cover_image_src: level.cover_image_src.clone(),
cover_asset_id: level.cover_asset_id.clone(),
publication_status: "draft".to_string(),
updated_at: format_timestamp_micros(now),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
anchor_pack,
publish_ready: true,
levels: vec![level.clone()],
};
Ok(json_success_body(
Some(&request_context),
PuzzleOnboardingGenerateResponse {
item: map_puzzle_work_profile_response(&state, item.clone()).summary,
level: map_puzzle_draft_level_response(level),
},
))
}
pub async fn save_puzzle_onboarding_work(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_WORKS_PROVIDER,
"message": error.body_text(),
})),
)
})?;
let prompt_text = payload.prompt_text.trim().to_string();
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&prompt_text,
"promptText",
)?;
let first_level = payload.item.levels.first().cloned().ok_or_else(|| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_WORKS_PROVIDER,
"message": "新手引导拼图缺少可保存关卡",
})),
)
})?;
let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?;
let work_title = payload.item.work_title.trim();
let work_title = if work_title.is_empty() {
first_level.level_name.clone()
} else {
work_title.to_string()
};
let work_description = payload.item.work_description.trim();
let work_description = if work_description.is_empty() {
prompt_text.clone()
} else {
work_description.to_string()
};
let summary = payload.item.summary.trim();
let summary = if summary.is_empty() {
first_level.picture_description.clone()
} else {
summary.to_string()
};
let now = current_utc_micros();
let owner_user_id = authenticated.claims().user_id().to_string();
let session_id = build_prefixed_uuid_id("puzzle-session-");
state
.spacetime_client()
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
seed_text: prompt_text.clone(),
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
welcome_message_text: build_puzzle_welcome_text(&prompt_text),
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let item = state
.spacetime_client()
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
profile_id,
owner_user_id,
work_title,
work_description,
level_name: first_level.level_name,
summary,
theme_tags: payload.item.theme_tags,
cover_image_src: first_level.cover_image_src,
cover_asset_id: first_level.cover_asset_id,
levels_json: Some(levels_json),
updated_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorkMutationResponse {
item: map_puzzle_work_profile_response(&state, item),
},
))
}
pub async fn get_puzzle_agent_session(
State(state): State<AppState>,
AxumPath(session_id): AxumPath<String>,

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -10,8 +10,8 @@ server-service = ["dep:platform-oss", "dep:reqwest"]
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true }
serde = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"], optional = true }
spacetimedb = { workspace = true, optional = true }
platform-oss = { path = "../platform-oss", optional = true }
shared-kernel = { path = "../shared-kernel" }
platform-oss = { workspace = true, optional = true }
shared-kernel = { workspace = true }

View File

@@ -5,12 +5,12 @@ version.workspace = true
license.workspace = true
[dependencies]
platform-auth = { path = "../platform-auth" }
shared-kernel = { path = "../shared-kernel" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["formatting", "parsing"] }
tracing = "0.1"
platform-auth = { workspace = true }
shared-kernel = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
time = { workspace = true, features = ["formatting", "parsing"] }
tracing = { workspace = true }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb", "module-runtime-item/spacetime-types"]
[dependencies]
module-runtime-item = { path = "../module-runtime-item", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-runtime-item = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde = { workspace = true }
serde_json = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -9,7 +9,7 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
module-inventory = { path = "../module-inventory", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-inventory = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -5,7 +5,7 @@ version.workspace = true
license.workspace = true
[dependencies]
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["formatting"] }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
time = { workspace = true, features = ["formatting"] }

View File

@@ -9,8 +9,8 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }
time = { version = "0.3", features = ["formatting", "parsing"] }
time = { workspace = true, features = ["formatting", "parsing"] }

View File

@@ -9,6 +9,6 @@ default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -16,11 +16,11 @@ spacetime-types = [
]
[dependencies]
module-combat = { path = "../module-combat", default-features = false }
module-inventory = { path = "../module-inventory", default-features = false }
module-progression = { path = "../module-progression", default-features = false }
module-quest = { path = "../module-quest", default-features = false }
module-runtime-item = { path = "../module-runtime-item", default-features = false }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
module-combat = { workspace = true }
module-inventory = { workspace = true }
module-progression = { workspace = true }
module-quest = { workspace = true }
module-runtime-item = { workspace = true }
serde = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -5,21 +5,20 @@ version.workspace = true
license.workspace = true
[dependencies]
argon2 = "0.5"
base64 = "0.22"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
jsonwebtoken = "9"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["std"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
argon2 = { workspace = true }
hmac = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde_json = { workspace = true }
serde_urlencoded = { workspace = true }
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
rand_core = { workspace = true, features = ["getrandom"] }
serde = { workspace = true }
shared-kernel = { workspace = true }
time = { workspace = true, features = ["std"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -5,7 +5,6 @@ use std::{
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
@@ -14,7 +13,6 @@ use rand_core::OsRng;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
@@ -43,7 +41,7 @@ pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
"https://api.weixin.qq.com/sns/oauth2/access_token";
pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -927,14 +925,6 @@ impl AliyunSmsAuthProvider {
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
query.insert("Format".to_string(), "json".to_string());
query.insert("Version".to_string(), "2017-05-25".to_string());
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
@@ -971,11 +961,12 @@ impl AliyunSmsAuthProvider {
if let Some(scheme_name) = self.config.scheme_name.clone() {
query.insert("SchemeName".to_string(), scheme_name);
}
self.sign_query(&mut query)?;
let signature_headers = self.build_signature_headers("SendSmsVerifyCode", &query)?;
let payload = self
.client
.post(build_aliyun_sms_url(&self.config.endpoint)?)
.headers(signature_headers)
.form(&query)
.send()
.await
@@ -1053,14 +1044,6 @@ impl AliyunSmsAuthProvider {
query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string());
query.insert("Format".to_string(), "json".to_string());
query.insert("Version".to_string(), "2017-05-25".to_string());
query.insert("Timestamp".to_string(), current_aliyun_timestamp());
query.insert("SignatureNonce".to_string(), new_uuid_simple_string());
query.insert("SignatureMethod".to_string(), "HMAC-SHA1".to_string());
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
@@ -1080,11 +1063,12 @@ impl AliyunSmsAuthProvider {
if let Some(provider_out_id) = request.provider_out_id {
query.insert("OutId".to_string(), provider_out_id);
}
self.sign_query(&mut query)?;
let signature_headers = self.build_signature_headers("CheckSmsVerifyCode", &query)?;
let payload = self
.client
.post(build_aliyun_sms_url(&self.config.endpoint)?)
.headers(signature_headers)
.form(&query)
.send()
.await
@@ -1105,24 +1089,48 @@ impl AliyunSmsAuthProvider {
Ok(())
}
fn sign_query(&self, query: &mut BTreeMap<String, String>) -> Result<(), SmsProviderError> {
fn build_signature_headers(
&self,
action: &str,
form: &BTreeMap<String, String>,
) -> Result<reqwest::header::HeaderMap, SmsProviderError> {
let access_key_id = self.config.access_key_id.as_deref().ok_or_else(|| {
SmsProviderError::InvalidConfig("阿里云短信 AccessKeyId 未配置".to_string())
})?;
let access_key_secret = self.config.access_key_secret.as_deref().ok_or_else(|| {
SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string())
})?;
let canonicalized = canonicalize_aliyun_rpc_params(query);
let string_to_sign = format!(
"POST&{}&{}",
aliyun_percent_encode("/"),
aliyun_percent_encode(&canonicalized)
let date = current_aliyun_timestamp();
let nonce = new_uuid_simple_string();
let payload = build_aliyun_form_body(form);
let payload_hash = sha256_hex(payload.as_bytes());
let canonical_headers = format!(
"host:{}\nx-acs-action:{}\nx-acs-content-sha256:{}\nx-acs-date:{}\nx-acs-signature-nonce:{}\nx-acs-version:2017-05-25\n",
self.config.endpoint, action, payload_hash, date, nonce
);
let mut signer = HmacSha1::new_from_slice(format!("{access_key_secret}&").as_bytes())
.map_err(|error| {
SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}"))
})?;
signer.update(string_to_sign.as_bytes());
let signature = BASE64_STANDARD.encode(signer.finalize().into_bytes());
query.insert("Signature".to_string(), signature);
Ok(())
let signed_headers =
"host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version";
let canonical_request = format!(
"POST\n/\n\n{}\n{}\n{}",
canonical_headers, signed_headers, payload_hash
);
let string_to_sign = format!(
"ACS3-HMAC-SHA256\n{}",
sha256_hex(canonical_request.as_bytes())
);
let signature = hmac_sha256_hex(access_key_secret.as_bytes(), string_to_sign.as_bytes())?;
let authorization = format!(
"ACS3-HMAC-SHA256 Credential={access_key_id},SignedHeaders={signed_headers},Signature={signature}"
);
let mut headers = reqwest::header::HeaderMap::new();
insert_header(&mut headers, "x-acs-action", action)?;
insert_header(&mut headers, "x-acs-version", "2017-05-25")?;
insert_header(&mut headers, "x-acs-date", &date)?;
insert_header(&mut headers, "x-acs-signature-nonce", &nonce)?;
insert_header(&mut headers, "x-acs-content-sha256", &payload_hash)?;
insert_header(&mut headers, "authorization", &authorization)?;
Ok(headers)
}
}
@@ -1448,15 +1456,24 @@ fn build_aliyun_sms_url(endpoint: &str) -> Result<String, SmsProviderError> {
}
fn current_aliyun_timestamp() -> String {
OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
// 阿里云 OpenAPI ACS3 签名头 x-acs-date 要求使用不带小数秒的 UTC ISO 8601 格式,
// 即 yyyy-MM-dd'T'HH:mm:ss'Z'。time crate 的 Rfc3339 会保留纳秒,
// 形如 2026-05-07T14:23:59.364767Z,阿里云网关会判定为时间格式非法。
let now = OffsetDateTime::now_utc();
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second()
)
}
fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
fn canonicalize_aliyun_form_params(params: &BTreeMap<String, String>) -> String {
params
.iter()
.filter(|(key, _)| key.as_str() != "Signature")
.map(|(key, value)| {
format!(
"{}={}",
@@ -1468,6 +1485,43 @@ fn canonicalize_aliyun_rpc_params(params: &BTreeMap<String, String>) -> String {
.join("&")
}
fn build_aliyun_form_body(params: &BTreeMap<String, String>) -> String {
serde_urlencoded::to_string(params).unwrap_or_else(|_| canonicalize_aliyun_form_params(params))
}
fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result<String, SmsProviderError> {
let mut signer = HmacSha256::new_from_slice(key).map_err(|error| {
SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}"))
})?;
signer.update(content);
Ok(hex_lower(&signer.finalize().into_bytes()))
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn insert_header(
headers: &mut reqwest::header::HeaderMap,
name: &'static str,
value: &str,
) -> Result<(), SmsProviderError> {
let value = reqwest::header::HeaderValue::from_str(value).map_err(|error| {
SmsProviderError::InvalidConfig(format!("构造阿里云短信签名头失败:{error}"))
})?;
headers.insert(reqwest::header::HeaderName::from_static(name), value);
Ok(())
}
fn aliyun_percent_encode(value: &str) -> String {
urlencoding::encode(value)
.into_owned()
@@ -2046,7 +2100,7 @@ mod tests {
}
#[test]
fn canonicalize_aliyun_rpc_params_keeps_sorted_percent_encoded_order() {
fn canonicalize_aliyun_form_params_keeps_sorted_percent_encoded_order() {
let mut params = BTreeMap::new();
params.insert(
"TemplateParam".to_string(),
@@ -2056,11 +2110,70 @@ mod tests {
params.insert("PhoneNumber".to_string(), "13800138000".to_string());
assert_eq!(
canonicalize_aliyun_rpc_params(&params),
canonicalize_aliyun_form_params(&params),
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
);
}
#[test]
fn aliyun_signature_headers_use_acs3_sha256() {
let config = SmsAuthConfig::new(
SmsAuthProviderKind::Aliyun,
DEFAULT_SMS_ENDPOINT.to_string(),
Some("test-access-key-id".to_string()),
Some("test-access-key-secret".to_string()),
"测试签名".to_string(),
"SMS_001".to_string(),
DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(),
DEFAULT_SMS_COUNTRY_CODE.to_string(),
None,
DEFAULT_SMS_CODE_LENGTH,
DEFAULT_SMS_CODE_TYPE,
DEFAULT_SMS_VALID_TIME_SECONDS,
DEFAULT_SMS_INTERVAL_SECONDS,
DEFAULT_SMS_DUPLICATE_POLICY,
DEFAULT_SMS_CASE_AUTH_POLICY,
false,
DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(),
)
.expect("aliyun config should build");
let provider = AliyunSmsAuthProvider {
client: Client::new(),
config,
};
let headers = provider
.build_signature_headers(
"SendSmsVerifyCode",
&BTreeMap::from([("Action".to_string(), "SendSmsVerifyCode".to_string())]),
)
.expect("signature headers should build");
let authorization = headers
.get(reqwest::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.expect("authorization header should exist");
assert!(authorization.starts_with("ACS3-HMAC-SHA256 Credential=test-access-key-id"));
assert!(headers.get("x-acs-content-sha256").is_some());
}
#[test]
fn current_aliyun_timestamp_uses_acs_iso8601_format_without_fractional_seconds() {
let timestamp = current_aliyun_timestamp();
assert_eq!(timestamp.len(), "2026-05-07T12:34:56Z".len());
assert_eq!(timestamp.as_bytes()[4], b'-');
assert_eq!(timestamp.as_bytes()[7], b'-');
assert_eq!(timestamp.as_bytes()[10], b'T');
assert_eq!(timestamp.as_bytes()[13], b':');
assert_eq!(timestamp.as_bytes()[16], b':');
assert!(timestamp.ends_with('Z'));
assert!(!timestamp.contains('.'));
assert!(timestamp.chars().enumerate().all(|(index, value)| {
matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || value.is_ascii_digit()
}));
}
#[test]
fn aliyun_send_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(

View File

@@ -5,11 +5,11 @@ version.workspace = true
license.workspace = true
[dependencies]
log.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time"] }
log = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -5,14 +5,13 @@ version.workspace = true
license.workspace = true
[dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
time = { version = "0.3", features = ["formatting"] }
base64 = { workspace = true }
hmac = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -1,21 +1,24 @@
use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime};
use std::{collections::BTreeMap, error::Error, fmt};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use httpdate::fmt_http_date;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60;
pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200;
pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024;
const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 8] = [
"generated-character-drafts",
@@ -171,9 +174,13 @@ pub struct OssPutObjectResponse {
pub struct OssPostObjectFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "Signature")]
#[serde(rename = "x-oss-signature-version")]
pub signature_version: String,
#[serde(rename = "x-oss-credential")]
pub credential: String,
#[serde(rename = "x-oss-date")]
pub date: String,
#[serde(rename = "x-oss-signature")]
pub signature: String,
#[serde(rename = "success_action_status")]
pub success_action_status: String,
@@ -394,6 +401,10 @@ impl OssClient {
.format(&Rfc3339)
.map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?;
let signed_at = OffsetDateTime::now_utc();
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let signature_date = build_v4_signature_date(signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let policy_json = build_policy_json(
&self.config.bucket,
&object_key,
@@ -402,14 +413,17 @@ impl OssClient {
success_action_status,
content_type.as_deref(),
&metadata,
&credential,
&signature_date,
);
let policy = serde_json::to_string(&policy_json)
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?;
let signature =
sign_v4_content(&self.config.access_key_secret, &signature_scope, &encoded_policy)?;
Ok(OssPostObjectResponse {
signature_version: "v1",
signature_version: "v4",
provider: "aliyun-oss",
bucket: self.config.bucket.clone(),
endpoint: self.config.endpoint.clone(),
@@ -425,7 +439,9 @@ impl OssClient {
form_fields: OssPostObjectFormFields {
key: object_key,
policy: encoded_policy,
oss_access_key_id: self.config.access_key_id.clone(),
signature_version: OSS_V4_ALGORITHM.to_string(),
credential,
date: signature_date,
signature,
success_action_status: success_action_status.to_string(),
content_type,
@@ -458,18 +474,48 @@ impl OssClient {
let expires_at_text = expires_at
.format(&Rfc3339)
.map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?;
let expires_epoch_seconds = expires_at.unix_timestamp();
let canonical_resource = build_canonical_object_resource(&self.config.bucket, &object_key);
let string_to_sign = format!("GET\n\n\n{expires_epoch_seconds}\n{canonical_resource}");
let signature = sign_policy(&self.config.access_key_secret, &string_to_sign)?;
let signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?;
let credential = format!("{}/{}", self.config.access_key_id, signature_scope);
let mut query = BTreeMap::from([
("x-oss-additional-headers".to_string(), "host".to_string()),
(
"x-oss-signature-version".to_string(),
OSS_V4_ALGORITHM.to_string(),
),
("x-oss-credential".to_string(), credential),
("x-oss-date".to_string(), signed_at_text),
("x-oss-expires".to_string(), expire_seconds.to_string()),
]);
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
let object_url_path = format!("/{}", encode_url_path(&object_key));
let additional_headers = "host";
let canonical_headers = format!(
"host:{}.{}\n",
self.config.bucket(),
self.config.endpoint()
);
let canonical_query = build_canonical_query_string(&query);
let canonical_request = build_v4_canonical_request(
Method::GET.as_str(),
&canonical_uri,
&canonical_query,
&canonical_headers,
additional_headers,
OSS_UNSIGNED_PAYLOAD,
);
let string_to_sign =
build_v4_string_to_sign(query["x-oss-date"].as_str(), &signature_scope, &canonical_request);
let signature =
sign_v4_content(&self.config.access_key_secret, &signature_scope, &string_to_sign)?;
query.insert("x-oss-signature".to_string(), signature);
let signed_url = format!(
"{}/{}?OSSAccessKeyId={}&Expires={}&Signature={}",
"{}{}?{}",
self.config.upload_host(),
encode_url_path(&object_key),
encode_url_query_value(&self.config.access_key_id),
expires_epoch_seconds,
encode_url_query_value(&signature)
object_url_path,
build_canonical_query_string(&query)
);
Ok(OssSignedGetObjectUrlResponse {
@@ -656,6 +702,8 @@ fn build_policy_json(
success_action_status: u16,
content_type: Option<&str>,
metadata: &BTreeMap<String, String>,
credential: &str,
signature_date: &str,
) -> Value {
let mut conditions = vec![
json!({ "bucket": bucket }),
@@ -666,6 +714,9 @@ fn build_policy_json(
"$success_action_status",
success_action_status.to_string()
]),
json!(["eq", "$x-oss-signature-version", OSS_V4_ALGORITHM]),
json!(["eq", "$x-oss-credential", credential]),
json!(["eq", "$x-oss-date", signature_date]),
];
if let Some(content_type) = content_type {
@@ -695,10 +746,6 @@ fn build_object_url(
Ok(url)
}
fn build_canonical_object_resource(bucket: &str, object_key: &str) -> String {
format!("/{bucket}/{object_key}")
}
fn build_object_key(
prefix: LegacyAssetPrefix,
path_segments: &[String],
@@ -928,14 +975,6 @@ fn collapse_dashes(value: &str) -> String {
.to_string()
}
fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result<String, OssError> {
let mut signer = HmacSha1::new_from_slice(access_key_secret.as_bytes())
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA1 失败:{error}")))?;
signer.update(encoded_policy.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
}
async fn send_signed_request(
client: &reqwest::Client,
config: &OssConfig,
@@ -966,29 +1005,52 @@ fn signed_request_builder(
content_type: Option<&str>,
oss_headers: &BTreeMap<String, String>,
) -> Result<reqwest::RequestBuilder, OssError> {
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => {
build_canonical_object_resource(config.bucket(), object_key.trim_start_matches('/'))
}
None => format!("/{}/", config.bucket()),
};
let canonicalized_oss_headers = build_canonicalized_oss_headers(oss_headers);
let string_to_sign = format!(
"{}\n\n{}\n{}\n{}{}",
let signed_at = OffsetDateTime::now_utc();
let signed_at_text = build_v4_signature_date(signed_at)?;
let signature_scope = build_v4_signature_scope(config.endpoint(), signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_v4_canonical_uri(config.bucket(), object_path);
let body_sha256 = OSS_UNSIGNED_PAYLOAD.to_string();
let mut signed_headers = BTreeMap::from([
(
"host".to_string(),
format!("{}.{}", config.bucket(), config.endpoint()),
),
("x-oss-content-sha256".to_string(), body_sha256.clone()),
("x-oss-date".to_string(), signed_at_text.clone()),
]);
if let Some(content_type) = content_type {
signed_headers.insert("content-type".to_string(), content_type.to_string());
}
for (key, value) in oss_headers {
signed_headers.insert(key.to_ascii_lowercase(), value.trim().to_string());
}
let canonical_headers = build_v4_canonical_headers(&signed_headers);
let additional_headers = "host";
let canonical_request = build_v4_canonical_request(
method.as_str(),
content_type.unwrap_or_default(),
date,
canonicalized_oss_headers,
canonical_resource
&canonical_uri,
"",
&canonical_headers,
additional_headers,
&body_sha256,
);
let signature = sign_policy(config.access_key_secret(), &string_to_sign)?;
let string_to_sign = build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_v4_content(config.access_key_secret(), &signature_scope, &string_to_sign)?;
let mut builder = client
.request(method, target_url)
.header("Date", date)
.header("x-oss-content-sha256", body_sha256)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!("OSS {}:{}", config.access_key_id(), signature),
format!(
"{OSS_V4_ALGORITHM} Credential={}/{},AdditionalHeaders={},Signature={}",
config.access_key_id(),
signature_scope,
additional_headers,
signature
),
);
if let Some(content_type) = content_type {
@@ -1002,13 +1064,160 @@ fn signed_request_builder(
Ok(builder)
}
fn build_canonicalized_oss_headers(headers: &BTreeMap<String, String>) -> String {
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
let region = extract_oss_region(endpoint)?;
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
}
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
let time = signed_at
.time()
.to_string()
.split('.')
.next()
.unwrap_or("00:00:00")
.replace(':', "");
if time.len() != 6 {
return Err(OssError::Sign("OSS V4 签名时间格式化失败".to_string()));
}
Ok(format!("{date}T{time}Z"))
}
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!(
"/{}/{}",
encode_url_query_value(bucket),
encode_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_url_query_value(bucket)),
}
}
fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
endpoint
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.map(str::to_string)
.filter(|region| !region.is_empty())
.ok_or_else(|| {
OssError::InvalidConfig(format!(
"OSS endpoint 无法解析 region当前值{endpoint}"
))
})
}
fn sign_v4_content(
access_key_secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, OssError> {
let signing_key = build_v4_signing_key(access_key_secret, signature_scope)?;
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
}
fn build_v4_signing_key(access_key_secret: &str, signature_scope: &str) -> Result<Vec<u8>, OssError> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少日期".to_string()))?;
let region = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 region".to_string()))?;
let service = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 service".to_string()))?;
let request = parts
.next()
.ok_or_else(|| OssError::Sign("OSS V4 签名 scope 缺少 request".to_string()))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{access_key_secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, OssError> {
let mut signer = HmacSha256::new_from_slice(key)
.map_err(|error| OssError::Sign(format!("初始化 HMAC-SHA256 失败:{error}")))?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
let mut signer = HmacSha256::new_from_slice(key)
.expect("HMAC-SHA256 accepts keys of any size");
signer.update(content);
hex_lower(&signer.finalize().into_bytes())
}
fn build_v4_canonical_request(
method: &str,
canonical_uri: &str,
canonical_query: &str,
canonical_headers: &str,
signed_headers: &str,
payload_hash: &str,
) -> String {
format!(
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
)
}
fn build_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"{OSS_V4_ALGORITHM}\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
headers
.iter()
.map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim()))
.collect::<String>()
}
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(key, value)| format!("{}={}", encode_url_query_value(key), encode_url_query_value(value)))
.collect::<Vec<_>>()
.join("&")
}
fn encode_url_path(path: &str) -> String {
path.split('/')
.map(encode_url_query_value)
@@ -1115,8 +1324,20 @@ mod tests {
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
assert_eq!(
response.form_fields.oss_access_key_id,
"test-access-key-id".to_string()
response.form_fields.signature_version,
OSS_V4_ALGORITHM.to_string()
);
assert!(response
.form_fields
.credential
.starts_with("test-access-key-id/"));
assert!(response
.form_fields
.credential
.ends_with("/cn-shanghai/oss/aliyun_v4_request"));
assert_eq!(
response.form_fields.date.len(),
"20260507T120000Z".len()
);
assert_eq!(
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
@@ -1169,6 +1390,18 @@ mod tests {
);
assert_eq!(
policy["conditions"][4],
json!(["eq", "$x-oss-signature-version", "OSS4-HMAC-SHA256"])
);
assert_eq!(
policy["conditions"][5],
json!(["eq", "$x-oss-credential", response.form_fields.credential])
);
assert_eq!(
policy["conditions"][6],
json!(["eq", "$x-oss-date", response.form_fields.date])
);
assert_eq!(
policy["conditions"][7],
json!(["eq", "$content-type", "image/png"])
);
assert_eq!(response.bucket, "genarrative-assets".to_string());
@@ -1206,10 +1439,13 @@ mod tests {
assert!(
response
.signed_url
.contains("OSSAccessKeyId=test-access-key-id")
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
);
assert!(response.signed_url.contains("&Expires="));
assert!(response.signed_url.contains("&Signature="));
assert!(response
.signed_url
.contains("x-oss-credential=test-access-key-id%2F"));
assert!(response.signed_url.contains("&x-oss-expires=300"));
assert!(response.signed_url.contains("&x-oss-signature="));
}
#[test]
@@ -1282,7 +1518,7 @@ mod tests {
}
#[test]
fn canonicalized_oss_headers_matches_oss_v1_upload_signature_shape() {
fn canonicalized_oss_headers_matches_sorted_v4_header_shape() {
let headers = BTreeMap::from([
(
"x-oss-meta-source-job-id".to_string(),
@@ -1295,7 +1531,7 @@ mod tests {
]);
assert_eq!(
build_canonicalized_oss_headers(&headers),
build_v4_canonical_headers(&headers),
"x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n"
);
}

View File

@@ -5,6 +5,6 @@ version.workspace = true
license.workspace = true
[dependencies]
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
platform-oss = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -105,3 +105,39 @@ pub struct AdminDebugHttpResponse {
pub body_text: String,
pub body_json: Option<Value>,
}
// 后台埋点明细查询参数只保留运营筛选需要的只读字段。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminTrackingEventListQuery {
pub event_key: Option<String>,
pub user_id: Option<String>,
pub scope_kind: Option<String>,
pub scope_id: Option<String>,
pub limit: Option<u32>,
}
// 单条埋点原始事件明细,字段与 tracking_event 表一一对应并补充事件名称。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminTrackingEventEntryPayload {
pub event_id: String,
pub event_key: String,
pub event_title: String,
pub scope_kind: String,
pub scope_id: String,
pub day_key: i64,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at: String,
}
// 后台埋点明细列表响应,前端导出 Excel 时直接使用 entries。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminTrackingEventListResponse {
pub entries: Vec<AdminTrackingEventEntryPayload>,
}

View File

@@ -525,9 +525,13 @@ pub struct DirectUploadTicketPayload {
pub struct DirectUploadTicketFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "Signature")]
#[serde(rename = "x-oss-signature-version")]
pub signature_version: String,
#[serde(rename = "x-oss-credential")]
pub credential: String,
#[serde(rename = "x-oss-date")]
pub date: String,
#[serde(rename = "x-oss-signature")]
pub signature: String,
#[serde(rename = "success_action_status")]
pub success_action_status: String,
@@ -615,7 +619,9 @@ impl From<OssPostObjectFormFields> for DirectUploadTicketFormFields {
Self {
key: value.key,
policy: value.policy,
oss_access_key_id: value.oss_access_key_id,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
@@ -703,7 +709,7 @@ mod tests {
fn direct_upload_ticket_response_keeps_form_fields_shape() {
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(OssPostObjectResponse {
signature_version: "v1",
signature_version: "v4",
provider: "aliyun-oss",
bucket: "genarrative-assets".to_string(),
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
@@ -719,7 +725,9 @@ mod tests {
form_fields: OssPostObjectFormFields {
key: "generated-characters/hero/master.png".to_string(),
policy: "policy".to_string(),
oss_access_key_id: "ak".to_string(),
signature_version: "OSS4-HMAC-SHA256".to_string(),
credential: "ak/20260507/cn-shanghai/oss/aliyun_v4_request".to_string(),
date: "20260507T120000Z".to_string(),
signature: "sig".to_string(),
success_action_status: "200".to_string(),
content_type: Some("image/png".to_string()),
@@ -732,10 +740,14 @@ mod tests {
})
.expect("payload should serialize");
assert_eq!(payload["upload"]["signatureVersion"], json!("v1"));
assert_eq!(payload["upload"]["signatureVersion"], json!("v4"));
assert_eq!(
payload["upload"]["formFields"]["OSSAccessKeyId"],
json!("ak")
payload["upload"]["formFields"]["x-oss-signature-version"],
json!("OSS4-HMAC-SHA256")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-credential"],
json!("ak/20260507/cn-shanghai/oss/aliyun_v4_request")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-meta-asset-kind"],

View File

@@ -127,3 +127,23 @@ pub struct PuzzleWorkDetailResponse {
pub struct PuzzleWorkMutationResponse {
pub item: PuzzleWorkProfileResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleOnboardingGenerateRequest {
pub prompt_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleOnboardingGenerateResponse {
pub item: PuzzleWorkSummaryResponse,
pub level: PuzzleDraftLevelResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleOnboardingSaveRequest {
pub prompt_text: String,
pub item: PuzzleWorkSummaryResponse,
}

View File

@@ -5,7 +5,7 @@ version.workspace = true
license.workspace = true
[dependencies]
time = { version = "0.3", features = ["formatting", "parsing"] }
time = { workspace = true, features = ["formatting", "parsing"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
uuid = { version = "1", features = ["v4"] }
uuid = { workspace = true, features = ["v4"] }

View File

@@ -5,4 +5,4 @@ version.workspace = true
license.workspace = true
[dependencies]
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }

View File

@@ -5,23 +5,23 @@ version.workspace = true
license.workspace = true
[dependencies]
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-square-hole = { path = "../module-square-hole" }
module-story = { path = "../module-story" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
spacetimedb-sdk = "2.1.0"
tokio = { version = "1", features = ["rt", "sync", "time"] }
module-ai = { workspace = true }
module-assets = { workspace = true }
module-big-fish = { workspace = true }
module-combat = { workspace = true }
module-custom-world = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb-sdk = { workspace = true }
tokio = { workspace = true, features = ["rt", "sync", "time"] }

View File

@@ -9,23 +9,23 @@ crate-type = ["cdylib"]
[dependencies]
log = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
spacetimedb-lib = { version = "=2.1.0", default-features = false, features = ["serde"] }
module-ai = { path = "../module-ai", default-features = false, features = ["spacetime-types"] }
module-assets = { path = "../module-assets", default-features = false, features = ["spacetime-types"] }
module-big-fish = { path = "../module-big-fish", default-features = false, features = ["spacetime-types"] }
module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] }
module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] }
module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] }
module-match3d = { path = "../module-match3d", default-features = false }
module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] }
module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] }
module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] }
module-quest = { path = "../module-quest", default-features = false, features = ["spacetime-types"] }
module-runtime = { path = "../module-runtime", default-features = false, features = ["spacetime-types"] }
module-runtime-item = { path = "../module-runtime-item", default-features = false, features = ["spacetime-types"] }
module-square-hole = { path = "../module-square-hole", default-features = false }
module-story = { path = "../module-story", default-features = false, features = ["spacetime-types"] }
shared-kernel = { path = "../shared-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-big-fish = { workspace = true, features = ["spacetime-types"] }
module-combat = { workspace = true, features = ["spacetime-types"] }
module-inventory = { workspace = true, features = ["spacetime-types"] }
module-custom-world = { workspace = true, features = ["spacetime-types"] }
module-match3d = { workspace = true }
module-npc = { workspace = true, features = ["spacetime-types"] }
module-puzzle = { workspace = true, features = ["spacetime-types"] }
module-progression = { workspace = true, features = ["spacetime-types"] }
module-quest = { workspace = true, features = ["spacetime-types"] }
module-runtime = { workspace = true, features = ["spacetime-types"] }
module-runtime-item = { workspace = true, features = ["spacetime-types"] }
module-square-hole = { workspace = true }
module-story = { workspace = true, features = ["spacetime-types"] }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, features = ["unstable"] }
spacetimedb-lib = { workspace = true, features = ["serde"] }

View File

@@ -1,8 +1,8 @@
use crate::runtime::analytics_date_dimension::analytics_date_dimension;
use crate::*;
use serde::{Deserialize, Serialize};
use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
use spacetimedb_lib::sats::ser::serde::SerializeWrapper;
use spacetimedb::sats::de::serde::DeserializeWrapper;
use spacetimedb::sats::ser::serde::SerializeWrapper;
use std::collections::HashSet;
use crate::big_fish::big_fish_runtime_run;

View File

@@ -87,14 +87,16 @@ const baseDraftItem: CustomWorldWorkSummary = {
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onCreateType={onCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
@@ -105,14 +107,21 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const squareHoleButton = screen.getByRole('button', { name: //u });
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
expect(puzzleButton).toBeTruthy();
expect(match3dButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(match3dButton);
expect(onCreateType).toHaveBeenCalledWith('match3d');
rerender(
<CustomWorldCreationHub

View File

@@ -44,9 +44,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).toContain('抓大鹅');
expect(html).toContain('经典消除玩法');
expect(html).not.toContain('角色扮演');
expect(html).not.toContain('大鱼吃小鱼');
expect(html).not.toContain('抓大鹅');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {

View File

@@ -32,34 +32,116 @@ type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').OrthographicCamera;
type PhysicsEntry = {
boundaryRadius: number;
colliderHeight: number;
item: Match3DItemSnapshot;
body: PhysicsBody;
lockReadableTop: boolean;
mesh: ThreeObject3D;
renderSignature: string;
spawnStartedAt: number;
targetY: number;
topRotationY: number;
};
type PendingPhysicsSpawn = {
activeLayerRank: number;
item: Match3DItemSnapshot;
renderSignature: string;
spawnAtMs: number;
layerCapacity: number;
targetY: number;
};
type StackHeightTarget = {
activeLayerRank: number;
targetY: number;
};
type BoardDepthPlan = {
activeDepth: number;
activeItemCount: number;
baseY: number;
initialDepth: number;
layerCapacity: number;
layerCount: number;
layerStep: number;
maxVerticalSpeed: number;
surfaceY: number;
};
type PhysicsStabilityPlan = {
angularDamping: number;
contactFriction: number;
contactRestitution: number;
linearDamping: number;
maxHorizontalSpeed: number;
maxVerticalSpeed: number;
solverIterations: number;
solverTolerance: number;
};
type Match3DSpawnTimingPlan = {
frameSpawnLimit: number;
initialDelayMs: number;
layerDelayMs: number;
burstSize: number;
staggerMs: number;
};
type Match3DSpawnHeightObstacle = {
boundaryRadius: number;
colliderHeight: number;
x: number;
y: number;
z: number;
};
type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
renderer: ThreeRenderer;
scene: ThreeScene;
spawnTimingPlan: Match3DSpawnTimingPlan;
stabilityPlan: PhysicsStabilityPlan;
world: PhysicsWorld;
three: ThreeModule;
cannon: CannonModule;
};
type Match3DStackHeightPlan = {
layerCapacity: number;
targets: Map<string, StackHeightTarget>;
};
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25;
const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024;
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60;
@@ -100,12 +182,350 @@ function toWorldPosition(item: Match3DItemSnapshot) {
};
}
export function resolveMatch3DBoardDepthPlan(
totalItemCount: number,
activeItemCount: number,
): BoardDepthPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const normalizedActiveItemCount = Math.max(
0,
Math.min(
normalizedTotalItemCount,
Math.round(Number.isFinite(activeItemCount) ? activeItemCount : 0),
),
);
const volumePressure = Math.max(0, normalizedTotalItemCount - 90);
const depthMax =
MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE +
volumePressure * MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE;
const initialDepth = Math.min(
depthMax,
MATCH3D_ITEM_VERTICAL_DEPTH_BASE +
Math.sqrt(normalizedTotalItemCount) *
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
);
const remainingRatio =
normalizedActiveItemCount / normalizedTotalItemCount;
const activeDepth =
normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
const pressureRatio = Math.min(1, volumePressure / 210);
const layerCapacity = Math.max(
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
Math.round(
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8,
),
);
const layerCount = Math.max(
1,
Math.ceil(normalizedActiveItemCount / layerCapacity),
);
const layerStep =
layerCount <= 1
? 0
: Math.min(
MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX,
activeDepth / Math.max(1, layerCount - 1),
);
return {
activeDepth,
activeItemCount: normalizedActiveItemCount,
baseY: MATCH3D_ITEM_BASE_HEIGHT,
initialDepth,
layerCapacity,
layerCount,
layerStep,
maxVerticalSpeed:
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
surfaceY: MATCH3D_ITEM_BASE_HEIGHT + initialDepth,
};
}
export function resolveMatch3DStackTargetY(
totalItemCount: number,
activeItemCount: number,
activeLayerRank: number,
) {
const depthPlan = resolveMatch3DBoardDepthPlan(
totalItemCount,
activeItemCount,
);
if (depthPlan.activeItemCount <= 1) {
return depthPlan.surfaceY;
}
const clampedRank = Math.max(
0,
Math.min(depthPlan.activeItemCount - 1, activeLayerRank),
);
const layerIndex = Math.floor(
(clampedRank / Math.max(1, depthPlan.activeItemCount - 1)) *
(depthPlan.layerCount - 1),
);
return (
depthPlan.surfaceY -
(depthPlan.layerCount - 1 - layerIndex) * depthPlan.layerStep
);
}
export function resolveMatch3DBoundaryRadius(
asset: Match3DGeometryAsset,
radius: number,
) {
const bounds = resolveMatch3DColliderBounds(asset, radius);
return Math.hypot(bounds.width / 2, bounds.depth / 2);
}
export function resolveMatch3DSpawnY(
plannedSpawnY: number,
colliderHeight: number,
boundaryRadius: number,
position: Pick<Match3DSpawnHeightObstacle, 'x' | 'z'>,
obstacles: readonly Match3DSpawnHeightObstacle[],
) {
const normalizedPlannedY = Number.isFinite(plannedSpawnY)
? plannedSpawnY
: MATCH3D_ITEM_BASE_HEIGHT;
const selfHalfHeight = Math.max(0, colliderHeight / 2);
const selfBoundaryRadius = Math.max(0, boundaryRadius);
return obstacles.reduce((spawnY, obstacle) => {
const horizontalDistance = Math.hypot(
position.x - obstacle.x,
position.z - obstacle.z,
);
const overlapDistance =
selfBoundaryRadius +
Math.max(0, obstacle.boundaryRadius) +
MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING;
if (horizontalDistance > overlapDistance) {
return spawnY;
}
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
const obstacleTopY =
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
return Math.max(
spawnY,
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
);
}, normalizedPlannedY);
}
export function resolveMatch3DSpawnDelay(
activeLayerRank: number,
layerCapacity = MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE,
timingPlan?: Pick<
Match3DSpawnTimingPlan,
'burstSize' | 'layerDelayMs' | 'staggerMs'
>,
) {
const normalizedLayerCapacity = Math.max(1, layerCapacity);
const normalizedLayerRank = Math.max(0, activeLayerRank);
const layerDelayMs =
timingPlan?.layerDelayMs ?? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS;
const staggerMs = timingPlan?.staggerMs ?? MATCH3D_ITEM_SPAWN_STAGGER_MS;
const burstSize = Math.max(
1,
timingPlan?.burstSize ?? normalizedLayerCapacity,
);
const layerIndex = Math.floor(
normalizedLayerRank / normalizedLayerCapacity,
);
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
return (
Math.max(layerIndex, burstIndex) * layerDelayMs +
(normalizedLayerRank % burstSize) * staggerMs
);
}
export function resolveMatch3DSpawnTimingPlan(
totalItemCount: number,
): Match3DSpawnTimingPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const crowdPressureRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 50) / 250,
);
const highCrowdRatio = Math.pow(crowdPressureRatio, 0.82);
const midCrowdRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 30) / 20,
);
return {
frameSpawnLimit:
normalizedTotalItemCount < 30
? 4
: normalizedTotalItemCount <= 120
? 2
: 1,
initialDelayMs: Math.round(
normalizedTotalItemCount < 30
? 220
: normalizedTotalItemCount <= 50
? 240 + midCrowdRatio * 40
: 260 + highCrowdRatio * 140,
),
layerDelayMs: Math.round(
normalizedTotalItemCount < 30
? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS
: normalizedTotalItemCount <= 50
? 96 + midCrowdRatio * 24
: 110 + highCrowdRatio * 50,
),
burstSize:
normalizedTotalItemCount < 30
? 8
: normalizedTotalItemCount <= 50
? 5
: normalizedTotalItemCount <= 120
? 7
: 6,
staggerMs: Math.round(
normalizedTotalItemCount < 30
? MATCH3D_ITEM_SPAWN_STAGGER_MS
: normalizedTotalItemCount <= 50
? 9 + midCrowdRatio * 3
: 12 + highCrowdRatio * 6,
),
};
}
function buildMatch3DStackHeightTargets(
run: Match3DRunSnapshot,
): Match3DStackHeightPlan {
const activeItems = run.items
.filter((item) => isItemState(item.state, 'in_board'))
.sort((left, right) => {
if (left.layer !== right.layer) {
return left.layer - right.layer;
}
return left.itemInstanceId.localeCompare(right.itemInstanceId);
});
const targets = new Map<string, StackHeightTarget>();
const depthPlan = resolveMatch3DBoardDepthPlan(
run.totalItemCount,
activeItems.length,
);
activeItems.forEach((item, activeLayerRank) => {
targets.set(
item.itemInstanceId,
{
activeLayerRank,
targetY: resolveMatch3DStackTargetY(
run.totalItemCount,
activeItems.length,
activeLayerRank,
),
},
);
});
return {
layerCapacity: depthPlan.layerCapacity,
targets,
};
}
export function resolveMatch3DPhysicsStabilityPlan(
totalItemCount: number,
): PhysicsStabilityPlan {
const normalizedTotalItemCount = Math.max(
1,
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
);
const pressureRatio = Math.min(
1,
Math.max(0, normalizedTotalItemCount - 90) / 210,
);
return {
angularDamping: 0.48 + pressureRatio * 0.18,
contactFriction: 0.55 + pressureRatio * 0.22,
contactRestitution: 0.28 - pressureRatio * 0.14,
linearDamping: 0.38 + pressureRatio * 0.2,
maxHorizontalSpeed:
MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT - pressureRatio * 0.9,
maxVerticalSpeed:
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
solverIterations: Math.round(10 + pressureRatio * 8),
solverTolerance: 0.001 - pressureRatio * 0.0006,
};
}
function applyDynamicStackLift(entry: PhysicsEntry) {
const liftDistance = entry.targetY - entry.body.position.y;
if (liftDistance <= 0.04) {
return;
}
const liftSpeed = Math.min(
MATCH3D_ITEM_LIFT_MAX_SPEED,
Math.max(0.7, liftDistance * 1.8),
);
// 中文注释:纵深只作为隐藏的表现层支撑;消除后给低层物体向上的托举,避免它们长期陷在锅底。
entry.body.force.y +=
entry.body.mass * liftDistance * MATCH3D_ITEM_LIFT_FORCE_SCALE;
entry.body.velocity.y = Math.max(entry.body.velocity.y, liftSpeed);
entry.body.wakeUp();
}
function applyStabilityPlanToBody(
entry: PhysicsEntry,
stabilityPlan: PhysicsStabilityPlan,
) {
const horizontalSpeed = Math.hypot(
entry.body.velocity.x,
entry.body.velocity.z,
);
if (horizontalSpeed > stabilityPlan.maxHorizontalSpeed) {
const ratio = stabilityPlan.maxHorizontalSpeed / horizontalSpeed;
entry.body.velocity.x *= ratio;
entry.body.velocity.z *= ratio;
}
entry.body.velocity.y = Math.max(
-stabilityPlan.maxVerticalSpeed,
Math.min(stabilityPlan.maxVerticalSpeed, entry.body.velocity.y),
);
}
function syncRuntimeStabilityPlan(
runtime: PhysicsRuntime,
totalItemCount: number,
) {
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
runtime.stabilityPlan = stabilityPlan;
runtime.world.defaultContactMaterial.friction =
stabilityPlan.contactFriction;
runtime.world.defaultContactMaterial.restitution =
stabilityPlan.contactRestitution;
const solver = runtime.world.solver as import('cannon-es').GSSolver;
solver.iterations = stabilityPlan.solverIterations;
solver.tolerance = stabilityPlan.solverTolerance;
runtime.entries.forEach((entry) => {
entry.body.angularDamping = stabilityPlan.angularDamping;
entry.body.linearDamping = stabilityPlan.linearDamping;
entry.body.sleepSpeedLimit = Math.max(
0.08,
0.16 - Math.min(1, totalItemCount / 300) * 0.05,
);
entry.body.sleepTimeLimit = 0.18;
});
}
function constrainBodyInsidePot(entry: PhysicsEntry) {
const visualRadius = toWorldPosition(entry.item).radius;
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
// 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。
const maxDistance = Math.max(
0,
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
);
const horizontalDistance = Math.hypot(
entry.body.position.x,
@@ -128,6 +548,16 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
}
}
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
return Math.min(
1,
Math.max(
0,
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
),
);
}
function applyCenterGravity(entry: PhysicsEntry) {
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
return;
@@ -536,6 +966,105 @@ function removePhysicsEntry(
runtime.entries.delete(itemInstanceId);
}
function createPhysicsEntryFromPendingSpawn(
runtime: PhysicsRuntime,
pendingSpawn: PendingPhysicsSpawn,
now: number,
) {
const visual = createItemMesh(runtime.three, pendingSpawn.item);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
const position = visual.position;
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
const horizontalDistance = Math.hypot(position.x, position.z);
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
const ratio = maxDistance / horizontalDistance;
position.x *= ratio;
position.z *= ratio;
}
const spawnLayerIndex = Math.floor(
Math.max(0, pendingSpawn.activeLayerRank) /
pendingSpawn.layerCapacity,
);
const plannedSpawnY =
pendingSpawn.targetY +
MATCH3D_ITEM_SPAWN_RISE_OFFSET +
Math.min(spawnLayerIndex * 0.05, 0.62);
const spawnY = resolveMatch3DSpawnY(
plannedSpawnY,
colliderBounds.height,
boundaryRadius,
position,
[...runtime.entries.values()].map((entry) => ({
boundaryRadius: entry.boundaryRadius,
colliderHeight: entry.colliderHeight,
x: entry.body.position.x,
y: entry.body.position.y,
z: entry.body.position.z,
})),
);
const body = new runtime.cannon.Body({
angularDamping: runtime.stabilityPlan.angularDamping,
allowSleep: true,
linearDamping: runtime.stabilityPlan.linearDamping,
mass: 1 + visual.radius * 0.7,
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
sleepSpeedLimit: 0.12,
sleepTimeLimit: 0.18,
position: new runtime.cannon.Vec3(
position.x,
spawnY,
position.z,
),
});
body.velocity.set(
((pendingSpawn.item.layer % 5) - 2) * 0.06,
-0.35,
(((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
);
body.angularVelocity.set(
0.1 + (pendingSpawn.item.layer % 3) * 0.025,
0.08,
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
);
visual.mesh.scale.setScalar(0.82);
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
body,
boundaryRadius,
colliderHeight: colliderBounds.height,
item: pendingSpawn.item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
renderSignature: pendingSpawn.renderSignature,
spawnStartedAt: now,
targetY: pendingSpawn.targetY,
topRotationY: visual.topRotationY,
});
}
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const readySpawns = [...runtime.pendingSpawns.entries()]
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
.sort((left, right) => {
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
return left[1].spawnAtMs - right[1].spawnAtMs;
}
if (left[1].activeLayerRank !== right[1].activeLayerRank) {
return left[1].activeLayerRank - right[1].activeLayerRank;
}
return left[0].localeCompare(right[0]);
});
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
runtime.pendingSpawns.delete(itemInstanceId);
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
});
}
function disposeRuntime(runtime: PhysicsRuntime | null) {
if (!runtime) {
return;
@@ -1092,13 +1621,23 @@ export function Match3DPhysicsBoard({
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
scene.add(rim);
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(
runRef.current.totalItemCount,
);
const spawnTimingPlan = resolveMatch3DSpawnTimingPlan(
runRef.current.totalItemCount,
);
const world = new cannon.World({
gravity: new cannon.Vec3(0, -6.2, 0),
});
world.allowSleep = true;
world.broadphase = new cannon.SAPBroadphase(world);
world.defaultContactMaterial.friction = 0.55;
world.defaultContactMaterial.restitution = 0.28;
world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
world.defaultContactMaterial.restitution =
stabilityPlan.contactRestitution;
const solver = world.solver as import('cannon-es').GSSolver;
solver.iterations = stabilityPlan.solverIterations;
solver.tolerance = stabilityPlan.solverTolerance;
const floorBody = new cannon.Body({
mass: 0,
@@ -1125,9 +1664,12 @@ export function Match3DPhysicsBoard({
animationId: null,
camera,
entries: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
renderer,
scene,
spawnTimingPlan,
stabilityPlan,
world,
three,
cannon,
@@ -1157,17 +1699,26 @@ export function Match3DPhysicsBoard({
}
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now;
flushPendingPhysicsSpawns(activeRuntime, now);
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
applyDynamicStackLift(entry);
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
constrainBodyInsidePot(entry);
});
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
applyDynamicStackLift(entry);
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
constrainBodyInsidePot(entry);
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
const spawnScale = 0.82 + spawnProgress * 0.18;
entry.mesh.scale.setScalar(spawnScale);
entry.mesh.position.set(
entry.body.position.x,
entry.body.position.y,
entry.body.position.y - (1 - spawnProgress) * 0.06,
entry.body.position.z,
);
entry.mesh.quaternion.set(
@@ -1231,6 +1782,16 @@ export function Match3DPhysicsBoard({
}
});
runtime.pendingSpawns.forEach((pendingSpawn, itemInstanceId) => {
if (!activeItemIds.has(itemInstanceId)) {
runtime.pendingSpawns.delete(itemInstanceId);
}
});
syncRuntimeStabilityPlan(runtime, run.totalItemCount);
runtime.spawnTimingPlan = resolveMatch3DSpawnTimingPlan(run.totalItemCount);
const stackHeightPlan = buildMatch3DStackHeightTargets(run);
run.items.forEach((item) => {
if (!isItemState(item.state, 'in_board')) {
return;
@@ -1247,44 +1808,46 @@ export function Match3DPhysicsBoard({
removePhysicsEntry(runtime, item.itemInstanceId, existing);
} else {
existing.item = item;
existing.targetY =
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
existing.body.position.y;
existing.mesh.visible = true;
return;
}
}
const visual = createItemMesh(runtime.three, item);
const asset = resolveGeometryAsset(item.visualKey);
const body = new runtime.cannon.Body({
angularDamping: 0.48,
linearDamping: 0.38,
mass: 1 + visual.radius * 0.7,
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
position: new runtime.cannon.Vec3(
visual.position.x,
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
visual.position.z,
),
});
body.velocity.set(
((item.layer % 5) - 2) * 0.08,
0,
(((item.layer + 2) % 5) - 2) * 0.08,
);
body.angularVelocity.set(
0.18 + (item.layer % 3) * 0.04,
0.12,
0.1 + (item.layer % 4) * 0.03,
);
const existingPending = runtime.pendingSpawns.get(item.itemInstanceId);
if (existingPending) {
if (existingPending.renderSignature !== renderSignature) {
runtime.pendingSpawns.delete(item.itemInstanceId);
} else {
existingPending.item = item;
existingPending.layerCapacity = stackHeightPlan.layerCapacity;
existingPending.targetY =
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
existingPending.targetY;
return;
}
}
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(item.itemInstanceId, {
body,
const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId);
const spawnAtMs =
performance.now() +
runtime.spawnTimingPlan.initialDelayMs +
resolveMatch3DSpawnDelay(
stackTarget?.activeLayerRank ?? item.layer - 1,
stackHeightPlan.layerCapacity,
runtime.spawnTimingPlan,
);
runtime.pendingSpawns.set(item.itemInstanceId, {
activeLayerRank: stackTarget?.activeLayerRank ?? item.layer - 1,
item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh,
layerCapacity: stackHeightPlan.layerCapacity,
renderSignature,
topRotationY: visual.topRotationY,
spawnAtMs,
targetY:
stackTarget?.targetY ??
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
});
});
}, [ready, run.items, run.runId, run.snapshotVersion]);

View File

@@ -25,6 +25,13 @@ import {
createMatch3DThreeGeometry,
measureMatch3DItemPreviewDimension,
resolveMatch3DColliderBounds,
resolveMatch3DBoardDepthPlan,
resolveMatch3DBoundaryRadius,
resolveMatch3DPhysicsStabilityPlan,
resolveMatch3DSpawnTimingPlan,
resolveMatch3DStackTargetY,
resolveMatch3DSpawnDelay,
resolveMatch3DSpawnY,
resolveMatch3DTrayPreviewRotation,
resolveMatch3DTrayPreviewReferenceDimension,
resolveMatch3DTrayPreviewScale,
@@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
).toBeCloseTo(cylinderBounds.height);
});
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);
expect(largeDepthPlan.initialDepth).toBeGreaterThan(
smallDepthPlan.initialDepth,
);
expect(largeDepthPlan.layerCapacity).toBeLessThan(
smallDepthPlan.layerCapacity,
);
expect(largeDepthPlan.layerCount).toBeGreaterThan(
smallDepthPlan.layerCount,
);
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
});
test('高数量 3D 局面使用更稳定的物理参数', () => {
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
expect(largePlan.contactFriction).toBeGreaterThan(
smallPlan.contactFriction,
);
expect(largePlan.contactRestitution).toBeLessThan(
smallPlan.contactRestitution,
);
expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
expect(largePlan.solverIterations).toBeGreaterThan(
smallPlan.solverIterations,
);
expect(largePlan.maxHorizontalSpeed).toBeLessThan(
smallPlan.maxHorizontalSpeed,
);
});
test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => {
const longBrick = resolveGeometryAsset('block-black-1x8');
const radius = 1;
const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
const visualRadius = Math.hypot(
resolveMatch3DColliderBounds(longBrick, radius).width / 2,
resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
);
expect(boundaryRadius).toBeCloseTo(visualRadius);
expect(boundaryRadius).toBeGreaterThan(2.4);
});
test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
120,
largeDepthPlan.layerCapacity,
);
const defaultCapacityDelay = resolveMatch3DSpawnDelay(
120,
smallDepthPlan.layerCapacity,
);
expect(bottomDelay).toBe(0);
expect(middleDelay).toBeGreaterThan(bottomDelay);
expect(topDelay).toBeGreaterThan(middleDelay);
expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
fastTimingPlan.frameSpawnLimit,
);
expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
fastTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
).toBeGreaterThan(450);
expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
smallTimingPlan.initialDelayMs,
);
expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
smallTimingPlan.frameSpawnLimit,
);
expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
smallTimingPlan.layerDelayMs,
);
expect(
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
).toBeGreaterThan(5000);
});
test('3D 新物体生成高度会避让同位置已有堆叠', () => {
const plannedSpawnY = 2;
const raisedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 0.18,
y: 2.4,
z: 0.15,
},
],
);
const unchangedSpawnY = resolveMatch3DSpawnY(
plannedSpawnY,
0.8,
0.7,
{ x: 0.1, z: 0.1 },
[
{
boundaryRadius: 0.7,
colliderHeight: 0.9,
x: 3,
y: 4,
z: 3,
},
],
);
expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
expect(unchangedSpawnY).toBe(plannedSpawnY);
});
test('积木视觉键不会被统一兜底成红色苹字', () => {
const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({

View File

@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { Loader2, Sparkles } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import {
lazy,
@@ -166,6 +166,10 @@ import {
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import {
generatePuzzleOnboardingWork,
savePuzzleOnboardingWork,
} from '../../services/puzzle-onboarding';
import {
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
@@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage =
| 'work-detail'
| 'platform';
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
type PuzzleOnboardingDraft = {
promptText: string;
item: PuzzleWorkSummary;
};
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
type SquareHoleRuntimeReturnStage =
@@ -605,6 +616,157 @@ function mergePuzzleWorkSummary(
return current.profileId === updated.profileId ? updated : current;
}
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
'genarrative.puzzle-onboarding.first-visit.v1';
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
function hasSeenPuzzleOnboarding() {
if (typeof window === 'undefined') {
return true;
}
try {
return (
window.localStorage.getItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY) ===
'1'
);
} catch {
return false;
}
}
function markPuzzleOnboardingSeen() {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY, '1');
} catch {
// 中文注释localStorage 不可写时只降级为本次会话展示,不影响主流程。
}
}
function PuzzleOnboardingView({
prompt,
phase,
error,
onPromptChange,
onSubmit,
}: {
prompt: string;
phase: PuzzleOnboardingPhase;
error: string | null;
onPromptChange: (value: string) => void;
onSubmit: () => void;
}) {
const isGenerating = phase === 'generating';
const isGenerated = phase === 'generated';
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
{isGenerating ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Sparkles className="h-6 w-6" />
)}
</div>
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
{PUZZLE_ONBOARDING_COPY}
</h1>
<form
className="flex w-full flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
value={prompt}
disabled={isGenerating || isGenerated}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="把你的梦讲给我听吧"
rows={4}
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
/>
<button
type="submit"
disabled={!canSubmit}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'生成'
)}
</button>
</form>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function PuzzleOnboardingLoginOverlay({
isSaving,
error,
onLogin,
}: {
isSaving: boolean;
error: string | null;
onLogin: () => void;
}) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
{isSaving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Sparkles className="h-5 w-5" />
)}
</div>
<h2 className="text-2xl font-black leading-tight">
{PUZZLE_ONBOARDING_CLEAR_COPY}
</h2>
<button
type="button"
disabled={isSaving}
onClick={onLogin}
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
/
</>
) : (
'注册账号 / 登录'
)}
</button>
{error ? (
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
{error}
</div>
) : null}
</section>
</div>
);
}
function mergeBigFishWorkSummary(
current: BigFishWorkSummary,
updated: BigFishWorkSummary,
@@ -1124,10 +1286,24 @@ export function PlatformEntryFlowShellImpl({
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleShelfError, setPuzzleShelfError] = useState<string | null>(null);
const [puzzleCreationError, setPuzzleCreationError] = useState<string | null>(
null,
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
useState<CreatePuzzleAgentSessionRequest | null>(null);
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
useState<PuzzleOnboardingPhase>('input');
const [puzzleOnboardingDraft, setPuzzleOnboardingDraft] =
useState<PuzzleOnboardingDraft | null>(null);
const [puzzleOnboardingError, setPuzzleOnboardingError] = useState<
string | null
>(null);
const [isPuzzleOnboardingSaving, setIsPuzzleOnboardingSaving] =
useState(false);
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
useState(false);
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
@@ -1295,9 +1471,9 @@ export function PlatformEntryFlowShellImpl({
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
setPuzzleShelfError(null);
} catch (error) {
setPuzzleError(
setPuzzleShelfError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
@@ -1609,6 +1785,106 @@ export function PlatformEntryFlowShellImpl({
[authUi],
);
const savePuzzleOnboardingDraft = useCallback(async () => {
if (!puzzleOnboardingDraft || isPuzzleOnboardingSaving) {
return;
}
setIsPuzzleOnboardingSaving(true);
setPuzzleOnboardingError(null);
try {
const response = await savePuzzleOnboardingWork({
promptText: puzzleOnboardingDraft.promptText,
item: puzzleOnboardingDraft.item,
});
setPuzzleWorks((current) => [response.item, ...current]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleOnboardingDraft(null);
setPuzzleOnboardingPrompt('');
setPuzzleOnboardingPhase('input');
platformBootstrap.setPlatformTab('home');
setSelectionStage('platform');
void refreshPuzzleShelf();
} catch (error) {
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '保存新手引导拼图失败。'),
);
} finally {
setIsPuzzleOnboardingSaving(false);
}
}, [
isPuzzleOnboardingSaving,
platformBootstrap,
puzzleOnboardingDraft,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestPuzzleOnboardingLogin = useCallback(() => {
if (isPuzzleOnboardingSaving) {
return;
}
authUi?.openLoginModal(() => {
void savePuzzleOnboardingDraft();
});
}, [authUi, isPuzzleOnboardingSaving, savePuzzleOnboardingDraft]);
useEffect(() => {
if (
!authUi ||
authUi?.user ||
selectionStage !== 'platform' ||
hasSeenPuzzleOnboarding()
) {
return;
}
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(null);
setSelectionStage('puzzle-onboarding');
}, [authUi, authUi?.user, selectionStage, setSelectionStage]);
const submitPuzzleOnboardingPrompt = useCallback(async () => {
const promptText = puzzleOnboardingPrompt.trim();
if (!promptText || puzzleOnboardingPhase === 'generating') {
return;
}
setPuzzleOnboardingPhase('generating');
setPuzzleOnboardingError(null);
try {
const response = await generatePuzzleOnboardingWork({ promptText });
const item: PuzzleWorkSummary = {
...response.item,
levels:
response.item.levels && response.item.levels.length > 0
? response.item.levels
: [response.level],
};
setPuzzleOnboardingDraft({ promptText, item });
setSelectedPuzzleDetail(item);
setPuzzleOnboardingPhase('generated');
markPuzzleOnboardingSeen();
window.setTimeout(() => {
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('platform');
setSelectionStage('puzzle-runtime');
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
} catch (error) {
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(
resolvePuzzleErrorMessage(error, '生成新手引导拼图失败。'),
);
}
}, [
puzzleOnboardingPhase,
puzzleOnboardingPrompt,
resolvePuzzleErrorMessage,
setSelectionStage,
]);
const requestDeleteCreationWork = useCallback(
(confirmation: DeleteCreationWorkConfirmation) => {
if (deletingCreationWorkId) {
@@ -1986,8 +2262,14 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab,
setSelectionStage,
onSessionOpened: () => {
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
setShowCreationTypeModal(false);
},
onOpenError: ({ errorMessage }) => {
sessionController.setCreationTypeError(errorMessage);
setPuzzleCreationError(errorMessage);
},
onActionComplete: async ({ payload, response, setSession }) => {
setPuzzleOperation(response.operation);
setSession(response.session);
@@ -2167,6 +2449,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
sessionController.setCreationTypeError(null);
setPuzzleCreationError(null);
const nextSession = await puzzleFlow.openWorkspace({});
if (nextSession) {
void refreshPuzzleShelf();
@@ -2274,6 +2558,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
@@ -4970,6 +5256,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleShelfError ??
puzzleError)
}
onRetry={() => {
@@ -4977,6 +5264,8 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
setMatch3DError(null);
setSquareHoleError(null);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
setPuzzleError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
@@ -4995,6 +5284,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError
}
createBusy={
@@ -5926,6 +6216,26 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'puzzle-onboarding' && (
<motion.div
key="puzzle-onboarding"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<PuzzleOnboardingView
prompt={puzzleOnboardingPrompt}
phase={puzzleOnboardingPhase}
error={puzzleOnboardingError}
onPromptChange={setPuzzleOnboardingPrompt}
onSubmit={() => {
void submitPuzzleOnboardingPrompt();
}}
/>
</motion.div>
)}
{selectionStage === 'puzzle-generating' && (
<motion.div
key="puzzle-generating"
@@ -6064,6 +6374,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleLeaderboardBusy
}
error={puzzleError}
hideBackButton={Boolean(puzzleOnboardingDraft)}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
@@ -6100,6 +6411,14 @@ export function PlatformEntryFlowShellImpl({
</div>
</div>
) : null}
{puzzleOnboardingDraft &&
puzzleRun?.currentLevel?.status === 'cleared' ? (
<PuzzleOnboardingLoginOverlay
isSaving={isPuzzleOnboardingSaving}
error={puzzleOnboardingError}
onLogin={requestPuzzleOnboardingLogin}
/>
) : null}
</motion.div>
)}
@@ -6345,6 +6664,7 @@ export function PlatformEntryFlowShellImpl({
bigFishError ??
match3dError ??
squareHoleError ??
puzzleCreationError ??
puzzleError ??
sessionController.creationTypeError
}

View File

@@ -11,8 +11,12 @@ test('platform creation types are derived from new work entry config', () => {
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'puzzle',
);
const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'match3d',
);
expect(puzzleConfig).toBeTruthy();
expect(match3dConfig).toBeTruthy();
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'puzzle',
@@ -23,6 +27,16 @@ test('platform creation types are derived from new work entry config', () => {
hidden: false,
}),
);
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: match3dConfig?.badge,
locked: false,
hidden: false,
}),
);
});
test('new work entry config controls visibility and open order', () => {
@@ -30,13 +44,14 @@ test('new work entry config controls visibility and open order', () => {
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
expect(visibleIds).not.toContain('rpg');
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds).not.toContain('match3d');
expect(visibleIds).toContain('match3d');
expect(visibleIds[0]).toBe('puzzle');
expect(visibleIds).toEqual([
'puzzle',
'match3d',
'square-hole',
'airp',
'visual-novel',

View File

@@ -31,6 +31,7 @@ export type SelectionStage =
| 'square-hole-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'puzzle-runtime'

View File

@@ -75,6 +75,10 @@ type PlatformCreationAgentFlowControllerOptions<
enterCreateTab: () => void;
setSelectionStage: (stage: SelectionStage) => void;
onSessionOpened?: () => void;
onOpenError?: (params: {
error: unknown;
errorMessage: string;
}) => void;
onActionComplete?: (params: {
payload: TActionPayload;
response: TActionResponse;
@@ -173,9 +177,15 @@ export function usePlatformCreationAgentFlowController<
options.setSelectionStage(options.workspaceStage);
return nextSession;
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.open),
const errorMessage = options.resolveErrorMessage(
caughtError,
options.errorMessages.open,
);
setError(errorMessage);
options.onOpenError?.({
error: caughtError,
errorMessage,
});
return null;
} finally {
setIsBusy(false);

View File

@@ -40,6 +40,7 @@ type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
hideBackButton?: boolean;
onBack: () => void;
onRemodelWork?: (profileId: string) => void | Promise<void>;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
@@ -306,6 +307,7 @@ export function PuzzleRuntimeShell({
run,
isBusy = false,
error = null,
hideBackButton = false,
onBack,
onRemodelWork,
onSwapPieces,
@@ -1095,7 +1097,10 @@ export function PuzzleRuntimeShell({
type="button"
onClick={handleBackRequest}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
disabled={hideBackButton}
className={`h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur ${
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
}`}
>
<ArrowLeft className="h-4 w-4" />
</button>
@@ -1732,7 +1737,9 @@ export function PuzzleRuntimeShell({
setIsSettingsPanelOpen(false);
onBack();
}}
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
className={`rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100 ${
hideBackButton ? 'hidden' : ''
}`}
>
</button>

View File

@@ -1143,6 +1143,10 @@ beforeEach(() => {
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
window.localStorage.setItem(
'genarrative.puzzle-onboarding.first-visit.v1',
'1',
);
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
totalPlayTimeMs: 0,
@@ -1869,22 +1873,25 @@ beforeEach(() => {
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreationHub(user);
const match3dButton = screen.getByRole('button', {
name: /.*/u,
});
const airpButton = screen.getByRole('button', { name: /AIRP/u });
const visualNovelButton = screen.getByRole('button', {
name: //u,
});
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
@@ -2841,7 +2848,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
expect(screen.queryByText(//u)).toBeNull();
});
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
@@ -2860,10 +2867,14 @@ test('hidden match3d creation card stays closed even when public galleries fail'
await openCreationHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('button', { name: /.*/u }),
).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
await user.click(
screen.getByRole('button', { name: /.*/u }),
);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('抓大鹅工作区match3d-agent-session-1')).toBeTruthy();
});
test('puzzle draft result back button returns to creation hub', async () => {

View File

@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
visible: false,
visible: true,
open: true,
},
{

View File

@@ -0,0 +1,5 @@
export {
generatePuzzleOnboardingWork,
puzzleOnboardingClient,
savePuzzleOnboardingWork,
} from './puzzleOnboardingClient';

View File

@@ -0,0 +1,62 @@
import type {
PuzzleOnboardingGenerateRequest,
PuzzleOnboardingGenerateResponse,
PuzzleOnboardingSaveRequest,
} from '../../../packages/shared/src/contracts/puzzleOnboarding';
import type { PuzzleWorkMutationResponse } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_ONBOARDING_API_BASE = '/api/runtime/puzzle/onboarding';
const PUZZLE_ONBOARDING_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 未登录首次访问生成的临时 1 关拼图,不写入用户作品库。
*/
export async function generatePuzzleOnboardingWork(
payload: PuzzleOnboardingGenerateRequest,
) {
return requestJson<PuzzleOnboardingGenerateResponse>(
`${PUZZLE_ONBOARDING_API_BASE}/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成新手引导拼图失败',
{
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
/**
* 登录后把临时拼图保存成当前用户的草稿作品。
*/
export async function savePuzzleOnboardingWork(
payload: PuzzleOnboardingSaveRequest,
) {
return requestJson<PuzzleWorkMutationResponse>(
`${PUZZLE_ONBOARDING_API_BASE}/save`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'保存新手引导拼图失败',
{
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
},
);
}
export const puzzleOnboardingClient = {
generate: generatePuzzleOnboardingWork,
save: savePuzzleOnboardingWork,
};