diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 38a9590a..b9830b54 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index aaf655cd..ffe8c85e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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`。 diff --git a/.hermes/shared-memory/project-overview.md b/.hermes/shared-memory/project-overview.md index 5918d97b..53b3aab7 100644 --- a/.hermes/shared-memory/project-overview.md +++ b/.hermes/shared-memory/project-overview.md @@ -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` diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 94d4fce1..4b84b645 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + `/admin/api/tracking/events${buildQueryString(query)}`, + {token}, + ); +} + export function listProfileRedeemCodes(token: string) { return request( '/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; diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 60be224b..63ffbacf 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index a200d35d..d1499ef0 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( ) : null} + {routeId === 'tracking' ? ( + + ) : null} {routeId === 'redeem' ? ( 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([]); + const [eventKey, setEventKey] = useState(''); + const [userId, setUserId] = useState(''); + const [scopeKind, setScopeKind] = useState(''); + const [scopeId, setScopeId] = useState(''); + const [limit, setLimit] = useState('200'); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [detailEntry, setDetailEntry] = + useState(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) { + event.preventDefault(); + void refreshTrackingEvents(); + } + + function handleExport() { + if (!entries.length) { + setErrorMessage('当前没有可导出的埋点数据'); + return; + } + exportTrackingEventsAsExcel(entries); + } + + return ( +
+
+
+

埋点数据

+

原始事件明细

+
+
+ + +
+
+ +
+
+ + + + + + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+

事件明细

+ {entries.length} 条 +
+
+ + + + + + + + + + + + + + {entries.length ? ( + entries.map((entry) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
事件Scope用户归属Metadata时间详情
+ {resolveEventTitle(entry)} + {entry.eventKey} + {entry.eventId} + + + {entry.scopeKind} + + {entry.scopeId || '-'} + dayKey: {entry.dayKey} + + {entry.userId || '-'} + owner: {entry.ownerUserId || '-'} + + {entry.profileId || '-'} + module: {entry.moduleKey || '-'} + +
+                        {formatMetadataJson(entry.metadataJson)}
+                      
+
{formatOccurredAt(entry.occurredAt)} + +
{isLoading ? '正在加载' : '暂无数据'}
+
+
+ + {detailEntry ? ( + setDetailEntry(null)} + /> + ) : null} +
+ ); +} + +function TrackingEventDetailPanel({ + entry, + onClose, +}: { + entry: AdminTrackingEventEntryPayload; + onClose: () => void; +}) { + return ( +
+
+
+

{resolveEventTitle(entry)}

+ +
+
+ {exportColumns.map((column) => ( +
+
{column.label}
+
+ {column.key === 'metadataJson' ? ( +
+                    {formatMetadataJson(entry.metadataJson)}
+                  
+ ) : ( + formatExportCell(entry[column.key], column.key) || '-' + )} +
+
+ ))} +
+
+
+ ); +} + +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) => `${escapeHtml(column.label)}`).join(''), + ...entries.map((entry) => + exportColumns + .map( + (column) => + `${escapeHtml(formatExportCell(entry[column.key], column.key))}`, + ) + .join(''), + ), + ]; + const html = `\uFEFF${tableRows + .map((row) => `${row}`) + .join('')}
`; + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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(''); +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index f889baa5..50f08553 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -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; } diff --git a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md new file mode 100644 index 00000000..b260b218 --- /dev/null +++ b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md @@ -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分钟创作、玩过就改、发布分享”的闭环。 diff --git a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md index 02cb7d6d..07225930 100644 --- a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md +++ b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md @@ -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 更接近装饰插画,产品主标凝聚力不足。 diff --git a/docs/design/README.md b/docs/design/README.md index 34a71240..d9041746 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 游戏全剧情的工作流程与交付模板。 diff --git a/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md b/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md new file mode 100644 index 00000000..dd2406d5 --- /dev/null +++ b/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md @@ -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. 保存完成后清空新手引导临时态,刷新拼图作品架,并回到产品首页。 diff --git a/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md b/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md new file mode 100644 index 00000000..65c75a40 --- /dev/null +++ b/docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md @@ -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:/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. 修复问题后提交并推送当前分支。 diff --git a/docs/technical/ALIYUN_SMS_TIMESTAMP_FORMAT_FIX_2026-05-07.md b/docs/technical/ALIYUN_SMS_TIMESTAMP_FORMAT_FIX_2026-05-07.md new file mode 100644 index 00000000..ca7fb89f --- /dev/null +++ b/docs/technical/ALIYUN_SMS_TIMESTAMP_FORMAT_FIX_2026-05-07.md @@ -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 +``` + +预期:相关测试通过,格式检查通过。 diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index 28c2d24d..83c1798c 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -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 diff --git a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md index e97ca5f7..003129f1 100644 --- a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md +++ b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md @@ -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. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。 diff --git a/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md b/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md index d5a6a6ff..be84d268 100644 --- a/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md +++ b/docs/technical/PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md @@ -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. 状态与快照 diff --git a/docs/technical/README.md b/docs/technical/README.md index ad0f19f2..df7aaf03 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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):记录方洞挑战结果页图片槽位局部生成、洞口图历史素材、运行态拖拽与点击投放交互的修正口径。 diff --git a/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md new file mode 100644 index 00000000..29fae1be --- /dev/null +++ b/docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md @@ -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。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index ffd7597c..c551e1a2 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -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 { diff --git a/packages/shared/src/contracts/puzzleOnboarding.ts b/packages/shared/src/contracts/puzzleOnboarding.ts new file mode 100644 index 00000000..b45a3974 --- /dev/null +++ b/packages/shared/src/contracts/puzzleOnboarding.ts @@ -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; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ffecc24..765cc3cb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/server-rs/.cargo/config.toml b/server-rs/.cargo/config.toml index 525e9b4e..727ba8da 100644 --- a/server-rs/.cargo/config.toml +++ b/server-rs/.cargo/config.toml @@ -1,3 +1,6 @@ +[build] +rustc-wrapper = "sccache" + [target.x86_64-unknown-linux-gnu] linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 8e1a4fba..1475ea09 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -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", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index ae27db5d..74af3855 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -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 会慢 diff --git a/server-rs/README.md b/server-rs/README.md index 947cca84..43143475 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -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) diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index dcb23ed2..6d190c19 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 00153464..a0c5efe6 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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, + Extension(request_context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, 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, mut request: Request, @@ -488,6 +505,290 @@ fn parse_count_value(value: &Value) -> Result { } } +async fn fetch_admin_tracking_events( + state: &AppState, + query: AdminTrackingEventListQuery, +) -> Result, 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 { + 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 { + // 本地开发清库后会通过 `/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 { + 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 { + 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::() + .await + .map_err(|error| format!("SQL 响应解析失败:{error}")) +} + +fn parse_admin_tracking_events_sql_response( + payload: Value, +) -> Result, String> { + let rows = extract_first_sql_rows(payload)?; + rows.iter() + .map(parse_admin_tracking_event_row) + .collect::, _>>() + .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, 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 { + 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 { + 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 { + columns.get(index).and_then(value_to_string) +} + +fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result { + 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::() + .map_err(|error| format!("埋点行 {field_name} 解析失败:{error}")), + _ => Err(format!("埋点行 {field_name} 类型非法")), + } +} + +fn value_to_string(value: &Value) -> Option { + 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 { + 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 { + 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 { + 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()); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6691a4d9..4abf378e 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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( diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index d0605df2..6dcc4828 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -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; + type HmacSha256 = Hmac; #[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> { - let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?; + fn build_oss_v4_signature_scope( + endpoint: &str, + signed_at: time::OffsetDateTime, + ) -> Result> { + 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> { + 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, Box> { + 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(®ion_key, service)?; + hmac_sha256_raw(&service_key, request) + } + + fn hmac_sha256_raw(key: &[u8], content: &str) -> Result, Box> { + 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::() + } + + fn encode_oss_url_path(path: &str) -> String { + path.split('/') + .map(encode_oss_url_query_value) + .collect::>() + .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> { diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 6917d6b1..96d76558 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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"); diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index c760c231..9496215a 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, 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, AxumPath(session_id): AxumPath, diff --git a/server-rs/crates/module-ai/Cargo.toml b/server-rs/crates/module-ai/Cargo.toml index fb8516e5..990e938f 100644 --- a/server-rs/crates/module-ai/Cargo.toml +++ b/server-rs/crates/module-ai/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-assets/Cargo.toml b/server-rs/crates/module-assets/Cargo.toml index f7293d8c..8522ac37 100644 --- a/server-rs/crates/module-assets/Cargo.toml +++ b/server-rs/crates/module-assets/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index b5749c82..eb7fa7b5 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/module-big-fish/Cargo.toml b/server-rs/crates/module-big-fish/Cargo.toml index f79978b1..e940f089 100644 --- a/server-rs/crates/module-big-fish/Cargo.toml +++ b/server-rs/crates/module-big-fish/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-combat/Cargo.toml b/server-rs/crates/module-combat/Cargo.toml index 30aa15b1..0600ba80 100644 --- a/server-rs/crates/module-combat/Cargo.toml +++ b/server-rs/crates/module-combat/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-custom-world/Cargo.toml b/server-rs/crates/module-custom-world/Cargo.toml index c14bdba6..fc587abe 100644 --- a/server-rs/crates/module-custom-world/Cargo.toml +++ b/server-rs/crates/module-custom-world/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-inventory/Cargo.toml b/server-rs/crates/module-inventory/Cargo.toml index b12531ba..4f41e104 100644 --- a/server-rs/crates/module-inventory/Cargo.toml +++ b/server-rs/crates/module-inventory/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-match3d/Cargo.toml b/server-rs/crates/module-match3d/Cargo.toml index 5e5042f3..2fd47fe7 100644 --- a/server-rs/crates/module-match3d/Cargo.toml +++ b/server-rs/crates/module-match3d/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-npc/Cargo.toml b/server-rs/crates/module-npc/Cargo.toml index 957bb219..17a32485 100644 --- a/server-rs/crates/module-npc/Cargo.toml +++ b/server-rs/crates/module-npc/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-progression/Cargo.toml b/server-rs/crates/module-progression/Cargo.toml index 9b87b8d6..3886bf46 100644 --- a/server-rs/crates/module-progression/Cargo.toml +++ b/server-rs/crates/module-progression/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-puzzle/Cargo.toml b/server-rs/crates/module-puzzle/Cargo.toml index abcd920f..90be0dc8 100644 --- a/server-rs/crates/module-puzzle/Cargo.toml +++ b/server-rs/crates/module-puzzle/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-quest/Cargo.toml b/server-rs/crates/module-quest/Cargo.toml index 25d81420..411ab570 100644 --- a/server-rs/crates/module-quest/Cargo.toml +++ b/server-rs/crates/module-quest/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-runtime-item/Cargo.toml b/server-rs/crates/module-runtime-item/Cargo.toml index 7378e261..fcb136ed 100644 --- a/server-rs/crates/module-runtime-item/Cargo.toml +++ b/server-rs/crates/module-runtime-item/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-runtime-story/Cargo.toml b/server-rs/crates/module-runtime-story/Cargo.toml index 8242922f..aa0253a6 100644 --- a/server-rs/crates/module-runtime-story/Cargo.toml +++ b/server-rs/crates/module-runtime-story/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/module-runtime/Cargo.toml b/server-rs/crates/module-runtime/Cargo.toml index 8e69e4ca..786ff327 100644 --- a/server-rs/crates/module-runtime/Cargo.toml +++ b/server-rs/crates/module-runtime/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/module-square-hole/Cargo.toml b/server-rs/crates/module-square-hole/Cargo.toml index 4d9c8e23..68ee1304 100644 --- a/server-rs/crates/module-square-hole/Cargo.toml +++ b/server-rs/crates/module-square-hole/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-story/Cargo.toml b/server-rs/crates/module-story/Cargo.toml index e34b603a..647a8ae7 100644 --- a/server-rs/crates/module-story/Cargo.toml +++ b/server-rs/crates/module-story/Cargo.toml @@ -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 } diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 1214d342..67cda152 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 6f05894d..bccf1127 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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; +type HmacSha256 = Hmac; // 鉴权 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) -> Result<(), SmsProviderError> { + fn build_signature_headers( + &self, + action: &str, + form: &BTreeMap, + ) -> Result { + 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 { } 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 { +fn canonicalize_aliyun_form_params(params: &BTreeMap) -> 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 { .join("&") } +fn build_aliyun_form_body(params: &BTreeMap) -> String { + serde_urlencoded::to_string(params).unwrap_or_else(|_| canonicalize_aliyun_form_params(params)) +} + +fn hmac_sha256_hex(key: &[u8], content: &[u8]) -> Result { + 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::() +} + +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(¶ms), + canonicalize_aliyun_form_params(¶ms), "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::( diff --git a/server-rs/crates/platform-llm/Cargo.toml b/server-rs/crates/platform-llm/Cargo.toml index 56971711..2fa04d3c 100644 --- a/server-rs/crates/platform-llm/Cargo.toml +++ b/server-rs/crates/platform-llm/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml index a8b56162..216e5955 100644 --- a/server-rs/crates/platform-oss/Cargo.toml +++ b/server-rs/crates/platform-oss/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 9d441d27..54c401d6 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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; +type HmacSha256 = Hmac; 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, + 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 { - 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, ) -> Result { - 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 { +fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result { + 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 { + 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 { + 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 { + 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, 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(®ion_key, service)?; + hmac_sha256_raw(&service_key, request) +} + +fn hmac_sha256_raw(key: &[u8], content: &str) -> Result, 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::() +} + +fn build_v4_canonical_headers(headers: &BTreeMap) -> String { headers .iter() .map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim())) .collect::() } +fn build_canonical_query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(key, value)| format!("{}={}", encode_url_query_value(key), encode_url_query_value(value))) + .collect::>() + .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" ); } diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index d2f74a6f..df973184 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -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 } diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 66ea8486..81fe5e33 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -105,3 +105,39 @@ pub struct AdminDebugHttpResponse { pub body_text: String, pub body_json: Option, } + +// 后台埋点明细查询参数只保留运营筛选需要的只读字段。 +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminTrackingEventListQuery { + pub event_key: Option, + pub user_id: Option, + pub scope_kind: Option, + pub scope_id: Option, + pub limit: Option, +} + +// 单条埋点原始事件明细,字段与 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, + pub owner_user_id: Option, + pub profile_id: Option, + pub module_key: Option, + 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, +} diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 8183dfc8..5a66a1c0 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -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 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"], diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index cdc14bb3..339b4f52 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -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, +} diff --git a/server-rs/crates/shared-kernel/Cargo.toml b/server-rs/crates/shared-kernel/Cargo.toml index f0b0e842..9af9e05b 100644 --- a/server-rs/crates/shared-kernel/Cargo.toml +++ b/server-rs/crates/shared-kernel/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/shared-logging/Cargo.toml b/server-rs/crates/shared-logging/Cargo.toml index 141faa54..75235916 100644 --- a/server-rs/crates/shared-logging/Cargo.toml +++ b/server-rs/crates/shared-logging/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 4b3203e2..26b0b849 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 743e0cc2..21377ff7 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 780e328e..b1a82e75 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index d5bf3db2..ef3eb456 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -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( {}} - 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( { 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', () => { diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx index 2adf4ad6..0f3c1e9d 100644 --- a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -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; + pendingSpawns: Map; raycaster: import('three').Raycaster; renderer: ThreeRenderer; scene: ThreeScene; + spawnTimingPlan: Match3DSpawnTimingPlan; + stabilityPlan: PhysicsStabilityPlan; world: PhysicsWorld; three: ThreeModule; cannon: CannonModule; }; +type Match3DStackHeightPlan = { + layerCapacity: number; + targets: Map; +}; + 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, + 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(); + 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]); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index bfccccca..4db8d3a6 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -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) => ({ diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index de3f31db..799fe92f 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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 ( +
+
+
+
+ {isGenerating ? ( + + ) : ( + + )} +
+

+ {PUZZLE_ONBOARDING_COPY} +

+
{ + event.preventDefault(); + onSubmit(); + }} + > +