From 199b44c18cc9fc3deb822e72b05a7d8996038f82 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 8 May 2026 21:47:45 +0800 Subject: [PATCH] Add backend feedback submission and image preview --- .env.local | 2 +- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + .../PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md | 38 ++-- ...FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md | 57 +++++ docs/technical/README.md | 1 + docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 14 +- packages/shared/src/contracts/runtime.ts | 33 +++ scripts/api-server-dev.mjs | 4 +- scripts/dev-rust-stack.sh | 3 +- server-rs/crates/api-server/src/app.rs | 13 +- server-rs/crates/api-server/src/main.rs | 4 +- .../crates/api-server/src/runtime_profile.rs | 111 ++++++++- .../crates/module-runtime/src/application.rs | 30 +++ .../crates/module-runtime/src/commands.rs | 101 +++++++++ server-rs/crates/module-runtime/src/domain.rs | 83 +++++++ server-rs/crates/module-runtime/src/errors.rs | 47 +++- .../crates/shared-contracts/src/runtime.rs | 79 +++++++ server-rs/crates/spacetime-client/src/lib.rs | 7 +- .../crates/spacetime-client/src/mapper.rs | 70 ++++++ .../src/module_bindings/mod.rs | 46 +++- .../profile_feedback_submission_table.rs | 167 ++++++++++++++ .../profile_feedback_submission_type.rs | 75 ++++++ ...gin_tracking_event_and_return_procedure.rs | 16 +- ...profile_feedback_evidence_snapshot_type.rs | 19 ++ .../runtime_profile_feedback_status_type.rs | 16 ++ ..._profile_feedback_submission_input_type.rs | 21 ++ ...edback_submission_procedure_result_type.rs | 19 ++ ...ofile_feedback_submission_snapshot_type.rs | 24 ++ ...t_profile_feedback_and_return_procedure.rs | 59 +++++ .../crates/spacetime-client/src/runtime.rs | 40 +++- .../crates/spacetime-module/src/migration.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 96 ++++++++ .../PlatformEntryFlowShellImpl.tsx | 6 +- .../PlatformFeedbackView.test.tsx | 53 ++++- .../platform-entry/PlatformFeedbackView.tsx | 214 ++++++++++-------- .../rpg-entry/rpgProfileClient.test.ts | 57 +++++ src/services/rpg-entry/rpgProfileClient.ts | 19 ++ 38 files changed, 1521 insertions(+), 140 deletions(-) create mode 100644 docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_evidence_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs diff --git a/.env.local b/.env.local index 4239e9bd..91845816 100644 --- a/.env.local +++ b/.env.local @@ -70,4 +70,4 @@ GENARRATIVE_SPACETIME_TOKEN="" # admin GENARRATIVE_ADMIN_USERNAME=admin GENARRATIVE_ADMIN_PASSWORD=123456 -ADMIN_API_TARGET=http://127.0.0.1:8082 +ADMIN_API_TARGET=http://127.0.0.1:3100 diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b9830b54..7d60443f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:修改 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-08 资料页反馈提交必须走 Rust 后端与 SpacetimeDB + +- 背景:`/profile/feedback` 首版页面曾只做前端成功态,无法沉淀到用户账号和数据库,也容易与主站平台主题脱节。 +- 决策:反馈提交统一走鉴权 HTTP 路由 `POST /api/profile/feedback`,由 `api-server` 取当前 access token 用户,调用 `spacetime-client` facade,再通过 `spacetime-module` procedure 写入私有表 `profile_feedback_submission`;前端只负责输入采集、Data URL 预览和提交元数据,不再保存 `File[]` 作为外部契约。 +- 影响范围:`src/components/platform-entry/PlatformFeedbackView.tsx`、`src/services/rpg-entry/rpgProfileClient.ts`、`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts`、`api-server`、`module-runtime`、`spacetime-client`、`spacetime-module`、表目录与 bindings。 +- 验证方式:前端定向测试应覆盖 Data URL 预览与 `/api/profile/feedback` 请求体;后端变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和生成绑定;API smoke 使用 `npm run api-server` 和 `/healthz`。 +- 关联文档:`docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。 + ## 2026-05-06 Maincloud 历史残留引用禁止再使用 - 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 266e8e0b..e4bc3114 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -75,6 +75,14 @@ - 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。 - 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。 +## 反馈页清空 file input 前必须先拷贝 FileList + +- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。 +- 原因:浏览器传入的 `FileList` 可能跟 `` 保持 live 绑定;如果先执行 `input.value = ''`,再从参数里的 `FileList` 读取文件,列表可能已经为空。 +- 处理:在清空 file input 前先执行 `const selectedFiles = files ? Array.from(files) : []`,后续图片类型、大小、Data URL 读取和预览都基于这个普通数组。 +- 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`。 +- 关联:`src/components/platform-entry/PlatformFeedbackView.tsx`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。 + ## 拼图 APIMart 图片生成密钥不能复用 DashScope / ARK key - 现象:拼图新手引导或拼图创作点击生成后返回 `APIMart 图片生成密钥未配置`。 diff --git a/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md b/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md index d16cc0e6..6c508715 100644 --- a/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md +++ b/docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md @@ -6,7 +6,7 @@ 在平台“我的”页签新增“反馈”入口。用户点击后进入独立路由 `/profile/feedback`,看到移动端优先的“帮助与反馈”表单页面,用于提交问题描述、上传问题截图凭证并选填联系电话。 -本次目标是补齐用户反馈入口和前端提交流程,不重新发明新的个人中心系统,不在“我的”页签当前面板下方展开表单。 +本次目标是补齐用户反馈入口、图片预览、后端提交接口和 SpacetimeDB 持久化闭环,不重新发明新的个人中心系统,不在“我的”页签当前面板下方展开表单。 ## 1. 参考图 @@ -16,8 +16,8 @@ 页面结构以该参考图为准: -1. 顶部白色导航栏,标题为“帮助与反馈”。 -2. 内容背景为浅灰色。 +1. 页面顶部保留项目风格返回按钮和标题“帮助与反馈”,不保留无实际功能的仿客户端顶部栏。 +2. 内容背景、卡片、按钮和状态色使用现有平台主题变量,避免硬编码成与主站不一致的蓝灰色页面。 3. 分区标题为“反馈问题”。 4. 第一张白色圆角卡片为“问题描述”。 5. 第二张白色圆角卡片为“上传凭证(提供问题截图)”。 @@ -41,21 +41,19 @@ - 展示虚线上传方块。 - 支持选择图片时,最多 4 张。 - 前端可预览已选图片。 - - 不接后端时,提交只进入前端成功态。 + - 已选图片使用 Data URL 预览,并随提交请求作为首版反馈凭证入库。 - 联系电话: - 选填。 - placeholder:`选填,如您填写则将会同步开发者与您联系`。 -- 提交后显示成功态。 +- 提交后通过 `POST /api/profile/feedback` 写入 SpacetimeDB,接口成功后显示成功态。 - 返回后回到平台首页并定位“我的”页签。 ### 2.2 不包含 -- 不新增后端反馈存储接口。 -- 不新增 SpacetimeDB 表结构和 migration。 - 不新增后台反馈记录管理页。 - 不实现真实“反馈与投诉记录”列表。 -如果后续要求真实存储反馈,需要另起后端 PRD/技术方案,覆盖 `shared-contracts`、`api-server`、SpacetimeDB 表与后台管理入口。 +后端接入细节以 [`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`](../technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md) 为准。 ## 3. 入口设计 @@ -80,15 +78,14 @@ ### 4.1 页面整体 - 移动端优先。 -- 背景为浅灰色或与现有平台浅色 surface 接近的背景。 -- 表单容器使用白色圆角卡片。 +- 背景、表单容器、输入框、提示和按钮复用 `platform-*` 主题变量。 - 桌面端居中展示,最大宽度不超过移动表单阅读范围,避免横向拉满。 -### 4.2 顶部栏 +### 4.2 返回区 - 标题:`帮助与反馈`。 -- 左侧返回/首页图标:点击返回平台首页“我的”页签。 -- 右侧胶囊控制区可不完全复刻;项目内若没有同类控件,保持简洁,不强行新增无实际功能按钮。 +- 左侧返回按钮:点击返回平台首页“我的”页签。 +- 不展示右侧胶囊控制区、假系统按钮或无实际功能顶部栏。 ### 4.3 问题描述卡片 @@ -107,6 +104,8 @@ - `(最多四张)` - 支持图片选择时,只允许 `image/*`。 - 超过 4 张时提示:`最多上传四张凭证`。 +- 单张图片最大 1MB,总大小最大 4MB。 +- 选择图片后必须立即展示缩略图预览,删除后从待提交凭证中移除。 ### 4.5 联系电话卡片 @@ -142,7 +141,7 @@ 3. 联系电话超过 40 字符:提示 `联系电话不能超过 40 字`。 4. 上传凭证超过 4 张:提示 `最多上传四张凭证`。 -校验通过后进入前端成功态。 +校验通过后调用 `POST /api/profile/feedback`,请求成功后进入成功态。 ## 7. 验收标准 @@ -154,6 +153,9 @@ - 空内容或少于 10 个字提交时显示校验错误。 - 有效内容提交后显示成功态。 - 上传凭证最多 4 张。 +- 上传凭证选择后显示图片预览。 +- 有效反馈提交后写入 `profile_feedback_submission` 表。 +- 提交接口返回 `feedbackId`、状态、提交时间和凭证元数据。 - 联系电话为空时可以提交。 - 返回后回到“我的”页签。 - 页面在 390×844 移动端视口不横向溢出。 @@ -165,8 +167,16 @@ - 路由:`src/routing/appPageRoutes.ts` - 阶段类型:`src/components/platform-entry/platformEntryTypes.ts` - 反馈页:`src/components/platform-entry/PlatformFeedbackView.tsx` +- 前端 profile client:`src/services/rpg-entry/rpgProfileClient.ts` - 我的页签入口:`src/components/rpg-entry/RpgEntryHomeView.tsx` - 页面接入:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- 共享契约:`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts/src/runtime.rs` +- 后端 API:`server-rs/crates/api-server/src/runtime_profile.rs` +- SpacetimeDB:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 领域规则:`server-rs/crates/module-runtime/src/*` - 测试: - `src/routing/appPageRoutes.test.ts` - `src/components/platform-entry/PlatformFeedbackView.test.tsx` + - `src/services/rpg-entry/rpgProfileClient.test.ts` + - `server-rs/crates/module-runtime` + - `server-rs/crates/api-server/src/runtime_profile.rs` diff --git a/docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md b/docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md new file mode 100644 index 00000000..6a76e8b8 --- /dev/null +++ b/docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md @@ -0,0 +1,57 @@ +# 个人反馈后端接入方案 + +更新时间:`2026-05-08` + +## 目标 + +`/profile/feedback` 不再停留在前端成功态,提交时必须经过 `api-server` 鉴权、`spacetime-client` facade、`spacetime-module` procedure,并持久化到 SpacetimeDB。 + +## 接口 + +- 路由:`POST /api/profile/feedback` +- 鉴权:必须登录,用户 ID 取 `AuthenticatedAccessToken`,前端不得上传或伪造 `userId`。 +- 请求体: + - `description`:必填,trim 后 10 至 200 字符。 + - `contactPhone`:选填,trim 后最多 40 字符。 + - `evidenceItems`:选填,最多 4 张图片。 + - 每张凭证包含 `fileName`、`contentType`、`sizeBytes`、`dataUrl`。 +- 响应体: + - `feedback.feedbackId` + - `feedback.status` + - `feedback.createdAt` + - `feedback.evidenceItems` 只回传凭证元数据,不回显 Data URL。 + +## 表结构 + +新增私有表 `profile_feedback_submission`: + +- `feedback_id PK: String` +- `user_id: String` +- `description: String` +- `contact_phone: Option` +- `evidence_json: String` +- `status: RuntimeProfileFeedbackStatus` +- `created_at: Timestamp` +- `updated_at: Timestamp` +- 索引:`user_id`、`(user_id, created_at)` + +`evidence_json` 保存首版图片凭证快照,后续如果迁移 OSS,应保持 HTTP 契约不变,仅替换内部 evidence 存储字段。 + +## 分层落点 + +- `shared-contracts`:冻结 HTTP DTO。 +- `module-runtime`:负责输入归一化、长度限制、图片数量/大小/Data URL 前缀校验、反馈 ID 和 evidence ID 生成、响应记录组装。 +- `spacetime-module`:新增 table 与 `submit_profile_feedback_and_return` procedure;只做事务写入和表到快照映射。 +- `spacetime-client`:新增 `submit_profile_feedback` facade,不让 `api-server` 直接依赖生成绑定。 +- `api-server`:新增鉴权 POST route,并对该 route 单独放宽 JSON body 上限。 +- 前端:`PlatformFeedbackView` 只负责临时表单状态、图片预览和调用 profile client;正式提交结果以后端返回为准。 +- 绑定生成:Windows 本地如遇 `sccache` 远端缓存被网络沙箱拦截,可临时使用仓库内短路径 `GENARRATIVE_BINDGEN_TEMP_ROOT` 并设置 `CARGO_BUILD_RUSTC_WRAPPER` 到本地 rustc passthrough wrapper 后重跑生成,不修改 `server-rs/.cargo/config.toml`。 + +## 验收 + +- 图片选择后能在页面看到缩略图。 +- 有效表单调用 `POST /api/profile/feedback` 并写入 `profile_feedback_submission`。 +- 未登录提交返回 `401`。 +- 超过图片数量、单张大小、总大小或非法 Data URL 时返回清晰校验错误。 +- `migration.rs`、SpacetimeDB 表目录、生成绑定同步更新。 +- 定向前端测试、Rust 领域测试和 API 认证测试通过。 diff --git a/docs/technical/README.md b/docs/technical/README.md index df7aaf03..6bbc2c56 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,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 差异。 +- [PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md](./PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md):冻结“我的”页签帮助与反馈入口的后端接入方案,覆盖 `POST /api/profile/feedback`、`profile_feedback_submission`、凭证图片 Data URL 校验和前端预览/提交边界。 - [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/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index b2fd99a0..0a027c6c 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -24,7 +24,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | --- | --- | | 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` | | 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` | -| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` | +| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_feedback_submission`, `profile_save_archive` | | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` | @@ -333,6 +333,18 @@ SELECT * FROM profile_recharge_order WHERE order_id = ''; SELECT * FROM profile_recharge_order WHERE user_id = '' ORDER BY created_at DESC; ``` +### `profile_feedback_submission` + +- 作用:保存“我的”页签帮助与反馈提交记录,关联当前登录用户的问题描述、选填联系电话和图片凭证快照。 +- 结构:`feedback_id PK: String`, `user_id: String`, `description: String`, `contact_phone: Option`, `evidence_json: String`, `status: RuntimeProfileFeedbackStatus`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:`user_id`, `(user_id, created_at)`。 +- 访问边界:私有表。前端只通过 `POST /api/profile/feedback` 提交,HTTP 回包只返回凭证元数据,不回显 `evidence_json` 中的 Data URL。 + +```sql +SELECT * FROM profile_feedback_submission WHERE feedback_id = ''; +SELECT * FROM profile_feedback_submission WHERE user_id = '' ORDER BY created_at DESC; +``` + ### `profile_save_archive` - 作用:用户存档列表,保存世界信息、封面、当前状态 JSON 和剧情 JSON。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 7eb08840..34b1fa6a 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -143,6 +143,39 @@ export type CreateProfileRechargeOrderResponse = { center: ProfileRechargeCenterResponse; }; +export type ProfileFeedbackStatus = 'open'; + +export type ProfileFeedbackEvidenceItemInput = { + fileName: string; + contentType: string; + sizeBytes: number; + dataUrl: string; +}; + +export type SubmitProfileFeedbackRequest = { + description: string; + contactPhone?: string | null; + evidenceItems: ProfileFeedbackEvidenceItemInput[]; +}; + +export type ProfileFeedbackEvidenceItem = { + evidenceId: string; + fileName: string; + contentType: string; + sizeBytes: number; +}; + +export type ProfileFeedbackSubmission = { + feedbackId: string; + status: ProfileFeedbackStatus; + createdAt: string; + evidenceItems: ProfileFeedbackEvidenceItem[]; +}; + +export type SubmitProfileFeedbackResponse = { + feedback: ProfileFeedbackSubmission; +}; + export type ProfileReferralInviteCenterResponse = { inviteCode: string; inviteLinkPath: string; diff --git a/scripts/api-server-dev.mjs b/scripts/api-server-dev.mjs index c90de96f..7a3c8d6d 100644 --- a/scripts/api-server-dev.mjs +++ b/scripts/api-server-dev.mjs @@ -35,9 +35,9 @@ function loadEnvFile(path, target) { } const mergedEnv = { ...process.env }; -loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); -loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); loadEnvFile(resolve(repoRoot, '.env.secrets.local'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1'; mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100'; diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 7b95920d..265e27fc 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -511,7 +511,8 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then printf '\n' | spacetime \ start \ --data-dir "${SPACETIME_DATA_DIR}" \ - --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" + --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ + --non-interactive ) 2>&1 | tee "${SPACETIME_START_LOG}" & PIDS+=("$!") NAMES+=("spacetimedb") diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 29ea65d0..fcb9ee9c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -116,7 +116,7 @@ use crate::{ claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, redeem_profile_reward_code, + redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -144,6 +144,7 @@ use crate::{ }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; +const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { @@ -1227,6 +1228,16 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/feedback", + post(submit_profile_feedback) + // 中文注释:反馈首版允许最多四张 1MB Data URL 图片,只给该接口放宽 body limit。 + .layer(DefaultBodyLimit::max(PROFILE_FEEDBACK_BODY_LIMIT_BYTES)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/referrals/invite-center", get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 96d76558..33d42ad7 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -100,9 +100,9 @@ fn run_api_server_with_runtime() -> Result<(), std::io::Error> { async fn run_api_server() -> Result<(), std::io::Error> { // 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。 - let _ = dotenvy::from_filename(".env"); - let _ = dotenvy::from_filename(".env.local"); let _ = dotenvy::from_filename(".env.secrets.local"); + let _ = dotenvy::from_filename(".env.local"); + let _ = dotenvy::from_filename(".env"); // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 let config = AppConfig::from_env(); diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 2e5ab5e2..8c1434de 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -5,7 +5,9 @@ use axum::{ response::Response, }; use module_runtime::{ - AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord, + AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, + RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, + RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, @@ -23,8 +25,8 @@ use shared_contracts::runtime::{ AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, - PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, + PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, + PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD, @@ -35,6 +37,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, + ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, @@ -44,8 +47,9 @@ use shared_contracts::runtime::{ ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, - RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE, - TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, + RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, + SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE, + TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, }; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; use spacetime_client::SpacetimeClientError; @@ -208,6 +212,51 @@ pub async fn create_profile_recharge_order( )) } +pub async fn submit_profile_feedback( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, Response> { + let user_id = authenticated.claims().user_id().to_string(); + let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let evidence_items = payload + .evidence_items + .into_iter() + .map(|item| RuntimeProfileFeedbackEvidenceSnapshot { + evidence_id: String::new(), + file_name: item.file_name, + content_type: item.content_type, + size_bytes: item.size_bytes, + data_url: item.data_url, + }) + .collect(); + + let record = state + .spacetime_client() + .submit_profile_feedback( + user_id, + payload.description, + payload.contact_phone, + evidence_items, + created_at_micros as i64, + ) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + SubmitProfileFeedbackResponse { + feedback: build_profile_feedback_submission_response(record), + }, + )) +} + pub async fn get_profile_referral_invite_center( State(state): State, Extension(request_context): Extension, @@ -782,6 +831,36 @@ fn build_profile_recharge_order_response( } } +fn build_profile_feedback_submission_response( + record: RuntimeProfileFeedbackSubmissionRecord, +) -> ProfileFeedbackSubmissionResponse { + ProfileFeedbackSubmissionResponse { + feedback_id: record.feedback_id, + status: match record.status { + module_runtime::RuntimeProfileFeedbackStatus::Open => { + PROFILE_FEEDBACK_STATUS_OPEN.to_string() + } + }, + created_at: record.created_at, + evidence_items: record + .evidence_items + .into_iter() + .map(build_profile_feedback_evidence_response) + .collect(), + } +} + +fn build_profile_feedback_evidence_response( + record: RuntimeProfileFeedbackEvidenceRecord, +) -> ProfileFeedbackEvidenceItemResponse { + ProfileFeedbackEvidenceItemResponse { + evidence_id: record.evidence_id, + file_name: record.file_name, + content_type: record.content_type, + size_bytes: record.size_bytes, + } +} + fn build_profile_referral_invite_center_response( record: RuntimeReferralInviteCenterRecord, ) -> ProfileReferralInviteCenterResponse { @@ -1245,6 +1324,27 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn profile_feedback_requires_authentication() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/profile/feedback") + .header("content-type", "application/json") + .body(Body::from( + r#"{"description":"反馈页面上传图片后没有显示预览"}"#, + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn profile_referral_invite_center_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1345,6 +1445,7 @@ mod tests { "/api/runtime/profile/wallet-ledger", "/api/runtime/profile/recharge-center", "/api/runtime/profile/recharge/orders", + "/api/runtime/profile/feedback", "/api/runtime/profile/referrals/invite-center", "/api/runtime/profile/referrals/redeem-code", "/api/runtime/profile/redeem-codes/redeem", diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 6985d778..52e77ae8 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -164,6 +164,36 @@ pub fn build_runtime_profile_recharge_order_record( } } +pub fn build_runtime_profile_feedback_submission_record( + snapshot: RuntimeProfileFeedbackSubmissionSnapshot, +) -> Result { + let evidence_items = serde_json::from_str::>( + &snapshot.evidence_json, + ) + .map_err(|_| RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)? + .into_iter() + .map(|item| RuntimeProfileFeedbackEvidenceRecord { + evidence_id: item.evidence_id, + file_name: item.file_name, + content_type: item.content_type, + size_bytes: item.size_bytes, + }) + .collect(); + + Ok(RuntimeProfileFeedbackSubmissionRecord { + feedback_id: snapshot.feedback_id, + user_id: snapshot.user_id, + description: snapshot.description, + contact_phone: snapshot.contact_phone, + evidence_items, + status: snapshot.status, + created_at: format_utc_micros(snapshot.created_at_micros), + created_at_micros: snapshot.created_at_micros, + updated_at: format_utc_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + }) +} + pub fn build_runtime_referral_invite_center_record( snapshot: RuntimeReferralInviteCenterSnapshot, ) -> RuntimeReferralInviteCenterRecord { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index f570c67b..6341ea9d 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -262,6 +262,83 @@ pub fn build_runtime_profile_recharge_order_create_input( }) } +pub fn build_runtime_profile_feedback_submission_input( + user_id: String, + description: String, + contact_phone: Option, + evidence_items: Vec, + created_at_micros: i64, +) -> Result { + let user_id = normalize_runtime_profile_user_id(user_id)?; + let description = normalize_required_string(description) + .ok_or(RuntimeProfileFieldError::MissingFeedbackDescription)?; + let description_chars = description.chars().count(); + if description_chars < PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS { + return Err(RuntimeProfileFieldError::FeedbackDescriptionTooShort); + } + if description_chars > PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS { + return Err(RuntimeProfileFieldError::FeedbackDescriptionTooLong); + } + + let contact_phone = normalize_optional_string(contact_phone); + if contact_phone + .as_deref() + .map(|value| value.chars().count() > PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS) + .unwrap_or(false) + { + return Err(RuntimeProfileFieldError::FeedbackContactPhoneTooLong); + } + if evidence_items.len() > PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT { + return Err(RuntimeProfileFieldError::TooManyFeedbackEvidenceItems); + } + + let feedback_id = build_runtime_profile_feedback_submission_id(&user_id, created_at_micros); + let mut total_size_bytes = 0u64; + let mut normalized_evidence_items = Vec::with_capacity(evidence_items.len()); + for (index, item) in evidence_items.into_iter().enumerate() { + let content_type = normalize_required_string(item.content_type) + .map(|value| value.to_ascii_lowercase()) + .ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType)?; + if !is_profile_feedback_image_content_type(&content_type) { + return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType); + } + if item.size_bytes == 0 || item.size_bytes > PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES { + return Err(RuntimeProfileFieldError::FeedbackEvidenceTooLarge); + } + + total_size_bytes = total_size_bytes + .checked_add(item.size_bytes) + .ok_or(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge)?; + if total_size_bytes > PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES { + return Err(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge); + } + + let data_url = normalize_required_string(item.data_url) + .ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?; + if !has_profile_feedback_data_url_prefix(&data_url, &content_type) { + return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl); + } + let file_name = normalize_optional_string(Some(item.file_name)) + .unwrap_or_else(|| format!("feedback-image-{}", index + 1)); + + normalized_evidence_items.push(RuntimeProfileFeedbackEvidenceSnapshot { + evidence_id: build_runtime_profile_feedback_evidence_id(&feedback_id, index), + file_name, + content_type, + size_bytes: item.size_bytes, + data_url, + }); + } + + Ok(RuntimeProfileFeedbackSubmissionInput { + user_id, + description, + contact_phone, + evidence_items: normalized_evidence_items, + created_at_micros, + }) +} + pub fn build_runtime_referral_invite_center_get_input( user_id: String, ) -> Result { @@ -595,6 +672,17 @@ pub fn build_runtime_browse_history_id( format!("{user_id}:{owner_user_id}:{profile_id}") } +pub fn build_runtime_profile_feedback_submission_id( + user_id: &str, + created_at_micros: i64, +) -> String { + format!("feedback:{}:{}", user_id.trim(), created_at_micros) +} + +pub fn build_runtime_profile_feedback_evidence_id(feedback_id: &str, index: usize) -> String { + format!("{}:evidence:{:02}", feedback_id.trim(), index + 1) +} + fn parse_utc_rfc3339_to_micros(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -630,6 +718,19 @@ fn normalize_current_story_json( .map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson) } +fn is_profile_feedback_image_content_type(value: &str) -> bool { + matches!( + value, + "image/png" | "image/jpeg" | "image/jpg" | "image/webp" | "image/gif" + ) +} + +fn has_profile_feedback_data_url_prefix(data_url: &str, content_type: &str) -> bool { + data_url + .to_ascii_lowercase() + .starts_with(&format!("data:{content_type};base64,")) +} + pub fn normalize_invite_code(value: String) -> Option { let normalized = value .trim() diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 76b0f81d..c5eea622 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -33,6 +33,12 @@ pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1; pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; +pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10; +pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200; +pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40; +pub const PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT: usize = 4; +pub const PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES: u64 = 1_048_576; +pub const PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES: u64 = 4_194_304; /// 分析日期维表的纯领域快照。 /// @@ -440,6 +446,83 @@ pub struct RuntimeProfileDashboardGetInput { pub user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileFeedbackStatus { + Open, +} + +impl RuntimeProfileFeedbackStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileFeedbackEvidenceSnapshot { + pub evidence_id: String, + pub file_name: String, + pub content_type: String, + pub size_bytes: u64, + pub data_url: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileFeedbackSubmissionInput { + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_items: Vec, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileFeedbackSubmissionSnapshot { + pub feedback_id: String, + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_json: String, + pub status: RuntimeProfileFeedbackStatus, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeProfileFeedbackSubmissionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeProfileFeedbackEvidenceRecord { + pub evidence_id: String, + pub file_name: String, + pub content_type: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeProfileFeedbackSubmissionRecord { + pub feedback_id: String, + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_items: Vec, + pub status: RuntimeProfileFeedbackStatus, + pub created_at: String, + pub created_at_micros: i64, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RuntimeTrackingScopeKind { diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index effe0f6d..0f48f663 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -2,7 +2,12 @@ //! //! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。 -use crate::MAX_BROWSE_HISTORY_BATCH_SIZE; +use crate::{ + MAX_BROWSE_HISTORY_BATCH_SIZE, PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS, + PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS, PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS, + PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES, PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT, + PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeSettingsFieldError { @@ -80,6 +85,15 @@ pub enum RuntimeProfileFieldError { }, NonPersistentRuntimeSnapshot, InvalidAnalyticsCalendarDate, + MissingFeedbackDescription, + FeedbackDescriptionTooShort, + FeedbackDescriptionTooLong, + FeedbackContactPhoneTooLong, + TooManyFeedbackEvidenceItems, + InvalidFeedbackEvidenceContentType, + InvalidFeedbackEvidenceDataUrl, + FeedbackEvidenceTooLarge, + FeedbackEvidenceTotalTooLarge, } impl std::fmt::Display for RuntimeProfileFieldError { @@ -140,6 +154,37 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::InvalidAnalyticsCalendarDate => { f.write_str("analytics_date_dimension.calendar_date 必须是合法 YYYY-MM-DD 日期") } + Self::MissingFeedbackDescription => f.write_str("反馈问题描述不能为空"), + Self::FeedbackDescriptionTooShort => write!( + f, + "请填写{}个字以上的问题描述", + PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS + ), + Self::FeedbackDescriptionTooLong => write!( + f, + "问题描述不能超过 {} 字", + PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS + ), + Self::FeedbackContactPhoneTooLong => write!( + f, + "联系电话不能超过 {} 字", + PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS + ), + Self::TooManyFeedbackEvidenceItems => { + write!(f, "最多上传{}张凭证", PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT) + } + Self::InvalidFeedbackEvidenceContentType => f.write_str("反馈凭证只支持图片类型"), + Self::InvalidFeedbackEvidenceDataUrl => f.write_str("反馈凭证图片数据无效"), + Self::FeedbackEvidenceTooLarge => write!( + f, + "单张反馈凭证不能超过 {} bytes", + PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES + ), + Self::FeedbackEvidenceTotalTooLarge => write!( + f, + "反馈凭证总大小不能超过 {} bytes", + PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES + ), } } } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 67ebe613..d0a56bd6 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -21,6 +21,7 @@ pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete"; pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable"; pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed"; pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled"; +pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open"; pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; pub const TRACKING_SCOPE_KIND_MODULE: &str = "module"; @@ -254,6 +255,49 @@ pub struct CreateProfileRechargeOrderResponse { pub center: ProfileRechargeCenterResponse, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileFeedbackEvidenceItemRequest { + pub file_name: String, + pub content_type: String, + pub size_bytes: u64, + pub data_url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SubmitProfileFeedbackRequest { + pub description: String, + #[serde(default)] + pub contact_phone: Option, + #[serde(default)] + pub evidence_items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileFeedbackEvidenceItemResponse { + pub evidence_id: String, + pub file_name: String, + pub content_type: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProfileFeedbackSubmissionResponse { + pub feedback_id: String, + pub status: String, + pub created_at: String, + pub evidence_items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SubmitProfileFeedbackResponse { + pub feedback: ProfileFeedbackSubmissionResponse, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProfileReferralInvitedUserResponse { @@ -1263,6 +1307,41 @@ mod tests { assert_eq!(payload.payment_channel, None); } + #[test] + fn profile_feedback_response_uses_camel_case_fields() { + let payload = serde_json::to_value(SubmitProfileFeedbackResponse { + feedback: ProfileFeedbackSubmissionResponse { + feedback_id: "feedback:user-1:1".to_string(), + status: PROFILE_FEEDBACK_STATUS_OPEN.to_string(), + created_at: "2026-05-08T10:00:00Z".to_string(), + evidence_items: vec![ProfileFeedbackEvidenceItemResponse { + evidence_id: "feedback:user-1:1:evidence:01".to_string(), + file_name: "问题截图.png".to_string(), + content_type: "image/png".to_string(), + size_bytes: 128, + }], + }, + }) + .expect("payload should serialize"); + + assert_eq!( + payload["feedback"]["feedbackId"], + json!("feedback:user-1:1") + ); + assert_eq!( + payload["feedback"]["status"], + json!(PROFILE_FEEDBACK_STATUS_OPEN) + ); + assert_eq!( + payload["feedback"]["evidenceItems"][0]["contentType"], + json!("image/png") + ); + assert_eq!( + payload["feedback"]["evidenceItems"][0]["sizeBytes"], + json!(128) + ); + } + #[test] fn profile_play_stats_response_uses_camel_case_fields() { let payload = serde_json::to_value(ProfilePlayStatsResponse { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1829a452..799ef39e 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -146,8 +146,9 @@ use module_puzzle::{ use module_runtime::{ AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, - RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, + RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, @@ -159,6 +160,8 @@ use module_runtime::{ build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, + build_runtime_profile_feedback_submission_input, + build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1ecc1f96..503abe91 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -161,6 +161,34 @@ impl From } } +impl From + for RuntimeProfileFeedbackSubmissionInput +{ + fn from(input: module_runtime::RuntimeProfileFeedbackSubmissionInput) -> Self { + Self { + user_id: input.user_id, + description: input.description, + contact_phone: input.contact_phone, + evidence_items: input.evidence_items.into_iter().map(Into::into).collect(), + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for RuntimeProfileFeedbackEvidenceSnapshot +{ + fn from(input: module_runtime::RuntimeProfileFeedbackEvidenceSnapshot) -> Self { + Self { + evidence_id: input.evidence_id, + file_name: input.file_name, + content_type: input.content_type, + size_bytes: input.size_bytes, + data_url: input.data_url, + } + } +} + impl From for RuntimeProfileRewardCodeRedeemInput { @@ -846,6 +874,23 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result( )) } +pub(crate) fn map_runtime_profile_feedback_submission_procedure_result( + result: RuntimeProfileFeedbackSubmissionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile feedback 快照"))?; + + build_runtime_profile_feedback_submission_record( + map_runtime_profile_feedback_submission_snapshot(snapshot), + ) + .map_err(SpacetimeClientError::validation_failed) +} + pub(crate) fn map_runtime_referral_invite_center_procedure_result( result: RuntimeReferralInviteCenterProcedureResult, ) -> Result { @@ -1999,6 +2044,21 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot( } } +pub(crate) fn map_runtime_profile_feedback_submission_snapshot( + snapshot: RuntimeProfileFeedbackSubmissionSnapshot, +) -> module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { + module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { + feedback_id: snapshot.feedback_id, + user_id: snapshot.user_id, + description: snapshot.description, + contact_phone: snapshot.contact_phone, + evidence_json: snapshot.evidence_json, + status: map_runtime_profile_feedback_status_back(snapshot.status), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_referral_invite_center_snapshot( snapshot: RuntimeReferralInviteCenterSnapshot, ) -> module_runtime::RuntimeReferralInviteCenterSnapshot { @@ -4644,6 +4704,16 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back( } } +pub(crate) fn map_runtime_profile_feedback_status_back( + value: crate::module_bindings::RuntimeProfileFeedbackStatus, +) -> module_runtime::RuntimeProfileFeedbackStatus { + match value { + crate::module_bindings::RuntimeProfileFeedbackStatus::Open => { + module_runtime::RuntimeProfileFeedbackStatus::Open + } + } +} + pub(crate) fn map_story_session_status(value: StorySessionStatus) -> DomainStorySessionStatus { match value { StorySessionStatus::Active => DomainStorySessionStatus::Active, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 841fd679..8ef3c5a1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.1.0 (commit 10a4779b1338eff3708493d87496b51842a7c412). +// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -399,6 +399,8 @@ pub mod player_progression_table; pub mod player_progression_type; pub mod profile_dashboard_state_table; pub mod profile_dashboard_state_type; +pub mod profile_feedback_submission_table; +pub mod profile_feedback_submission_type; pub mod profile_invite_code_table; pub mod profile_invite_code_type; pub mod profile_membership_table; @@ -567,6 +569,11 @@ pub mod runtime_platform_theme_type; pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_snapshot_type; +pub mod runtime_profile_feedback_evidence_snapshot_type; +pub mod runtime_profile_feedback_status_type; +pub mod runtime_profile_feedback_submission_input_type; +pub mod runtime_profile_feedback_submission_procedure_result_type; +pub mod runtime_profile_feedback_submission_snapshot_type; pub mod runtime_profile_invite_code_admin_list_input_type; pub mod runtime_profile_invite_code_admin_list_procedure_result_type; pub mod runtime_profile_invite_code_admin_procedure_result_type; @@ -643,8 +650,8 @@ pub mod runtime_snapshot_row_type; pub mod runtime_snapshot_table; pub mod runtime_snapshot_type; pub mod runtime_snapshot_upsert_input_type; -pub mod runtime_tracking_scope_kind_type; pub mod runtime_tracking_event_procedure_result_type; +pub mod runtime_tracking_scope_kind_type; pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_generated_images_procedure; pub mod seed_analytics_date_dimensions_reducer; @@ -703,6 +710,7 @@ pub mod submit_big_fish_input_procedure; pub mod submit_big_fish_message_procedure; pub mod submit_custom_world_agent_message_procedure; pub mod submit_match_3_d_agent_message_procedure; +pub mod submit_profile_feedback_and_return_procedure; pub mod submit_puzzle_agent_message_procedure; pub mod submit_puzzle_leaderboard_entry_procedure; pub mod submit_square_hole_agent_message_procedure; @@ -1135,6 +1143,8 @@ pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; pub use profile_dashboard_state_table::*; pub use profile_dashboard_state_type::ProfileDashboardState; +pub use profile_feedback_submission_table::*; +pub use profile_feedback_submission_type::ProfileFeedbackSubmission; pub use profile_invite_code_table::*; pub use profile_invite_code_type::ProfileInviteCode; pub use profile_membership_table::*; @@ -1303,6 +1313,11 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; +pub use runtime_profile_feedback_evidence_snapshot_type::RuntimeProfileFeedbackEvidenceSnapshot; +pub use runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; +pub use runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput; +pub use runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult; +pub use runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot; pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput; pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult; pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; @@ -1379,8 +1394,8 @@ pub use runtime_snapshot_row_type::RuntimeSnapshotRow; pub use runtime_snapshot_table::*; pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; -pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult; +pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; @@ -1439,6 +1454,7 @@ pub use submit_big_fish_input_procedure::submit_big_fish_input; pub use submit_big_fish_message_procedure::submit_big_fish_message; pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message; pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message; +pub use submit_profile_feedback_and_return_procedure::submit_profile_feedback_and_return; pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry; pub use submit_square_hole_agent_message_procedure::submit_square_hole_agent_message; @@ -1785,6 +1801,7 @@ pub struct DbUpdate { npc_state: __sdk::TableUpdate, player_progression: __sdk::TableUpdate, profile_dashboard_state: __sdk::TableUpdate, + profile_feedback_submission: __sdk::TableUpdate, profile_invite_code: __sdk::TableUpdate, profile_membership: __sdk::TableUpdate, profile_played_world: __sdk::TableUpdate, @@ -1936,6 +1953,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_dashboard_state" => db_update.profile_dashboard_state.append( profile_dashboard_state_table::parse_table_update(table_update)?, ), + "profile_feedback_submission" => db_update.profile_feedback_submission.append( + profile_feedback_submission_table::parse_table_update(table_update)?, + ), "profile_invite_code" => db_update .profile_invite_code .append(profile_invite_code_table::parse_table_update(table_update)?), @@ -2241,6 +2261,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_dashboard_state, ) .with_updates_by_pk(|row| &row.user_id); + diff.profile_feedback_submission = cache + .apply_diff_to_table::( + "profile_feedback_submission", + &self.profile_feedback_submission, + ) + .with_updates_by_pk(|row| &row.feedback_id); diff.profile_invite_code = cache .apply_diff_to_table::( "profile_invite_code", @@ -2531,6 +2557,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_dashboard_state" => db_update .profile_dashboard_state .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_feedback_submission" => db_update + .profile_feedback_submission + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_invite_code" => db_update .profile_invite_code .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -2757,6 +2786,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_dashboard_state" => db_update .profile_dashboard_state .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_feedback_submission" => db_update + .profile_feedback_submission + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_invite_code" => db_update .profile_invite_code .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -2915,6 +2947,7 @@ pub struct AppliedDiff<'r> { npc_state: __sdk::TableAppliedDiff<'r, NpcState>, player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>, profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, + profile_feedback_submission: __sdk::TableAppliedDiff<'r, ProfileFeedbackSubmission>, profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>, profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>, profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>, @@ -3127,6 +3160,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_dashboard_state, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_feedback_submission", + &self.profile_feedback_submission, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_invite_code", &self.profile_invite_code, @@ -3994,6 +4032,7 @@ impl __sdk::SpacetimeModule for RemoteModule { npc_state_table::register_table(client_cache); player_progression_table::register_table(client_cache); profile_dashboard_state_table::register_table(client_cache); + profile_feedback_submission_table::register_table(client_cache); profile_invite_code_table::register_table(client_cache); profile_membership_table::register_table(client_cache); profile_played_world_table::register_table(client_cache); @@ -4067,6 +4106,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "npc_state", "player_progression", "profile_dashboard_state", + "profile_feedback_submission", "profile_invite_code", "profile_membership", "profile_played_world", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_table.rs new file mode 100644 index 00000000..b65c8a5e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_table.rs @@ -0,0 +1,167 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_feedback_submission_type::ProfileFeedbackSubmission; +use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_feedback_submission`. +/// +/// Obtain a handle from the [`ProfileFeedbackSubmissionTableAccess::profile_feedback_submission`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_feedback_submission()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_feedback_submission().on_insert(...)`. +pub struct ProfileFeedbackSubmissionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_feedback_submission`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileFeedbackSubmissionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileFeedbackSubmissionTableHandle`], which mediates access to the table `profile_feedback_submission`. + fn profile_feedback_submission(&self) -> ProfileFeedbackSubmissionTableHandle<'_>; +} + +impl ProfileFeedbackSubmissionTableAccess for super::RemoteTables { + fn profile_feedback_submission(&self) -> ProfileFeedbackSubmissionTableHandle<'_> { + ProfileFeedbackSubmissionTableHandle { + imp: self + .imp + .get_table::("profile_feedback_submission"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileFeedbackSubmissionInsertCallbackId(__sdk::CallbackId); +pub struct ProfileFeedbackSubmissionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileFeedbackSubmissionTableHandle<'ctx> { + type Row = ProfileFeedbackSubmission; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileFeedbackSubmissionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileFeedbackSubmissionInsertCallbackId { + ProfileFeedbackSubmissionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileFeedbackSubmissionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileFeedbackSubmissionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileFeedbackSubmissionDeleteCallbackId { + ProfileFeedbackSubmissionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileFeedbackSubmissionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileFeedbackSubmissionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileFeedbackSubmissionTableHandle<'ctx> { + type UpdateCallbackId = ProfileFeedbackSubmissionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileFeedbackSubmissionUpdateCallbackId { + ProfileFeedbackSubmissionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileFeedbackSubmissionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `feedback_id` unique index on the table `profile_feedback_submission`, +/// which allows point queries on the field of the same name +/// via the [`ProfileFeedbackSubmissionFeedbackIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_feedback_submission().feedback_id().find(...)`. +pub struct ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileFeedbackSubmissionTableHandle<'ctx> { + /// Get a handle on the `feedback_id` unique index on the table `profile_feedback_submission`. + pub fn feedback_id(&self) -> ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> { + ProfileFeedbackSubmissionFeedbackIdUnique { + imp: self.imp.get_unique_constraint::("feedback_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> { + /// Find the subscribed row whose `feedback_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("profile_feedback_submission"); + _table.add_unique_constraint::("feedback_id", |row| &row.feedback_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileFeedbackSubmission`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_feedback_submissionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileFeedbackSubmission`. + fn profile_feedback_submission( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_feedback_submissionQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_feedback_submission( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_feedback_submission") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_type.rs new file mode 100644 index 00000000..f39787a8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_feedback_submission_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileFeedbackSubmission { + pub feedback_id: String, + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_json: String, + pub status: RuntimeProfileFeedbackStatus, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileFeedbackSubmission { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileFeedbackSubmission`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileFeedbackSubmissionCols { + pub feedback_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col, + pub contact_phone: __sdk::__query_builder::Col>, + pub evidence_json: __sdk::__query_builder::Col, + pub status: + __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileFeedbackSubmission { + type Cols = ProfileFeedbackSubmissionCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileFeedbackSubmissionCols { + feedback_id: __sdk::__query_builder::Col::new(table_name, "feedback_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + contact_phone: __sdk::__query_builder::Col::new(table_name, "contact_phone"), + evidence_json: __sdk::__query_builder::Col::new(table_name, "evidence_json"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileFeedbackSubmission`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileFeedbackSubmissionIxCols { + pub feedback_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileFeedbackSubmission { + type IxCols = ProfileFeedbackSubmissionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileFeedbackSubmissionIxCols { + feedback_id: __sdk::__query_builder::IxCol::new(table_name, "feedback_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileFeedbackSubmission {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs index 9365d335..c131f3ba 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_daily_login_tracking_event_and_return_procedure.rs @@ -34,10 +34,10 @@ pub trait record_daily_login_tracking_event_and_return { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -47,10 +47,10 @@ impl record_daily_login_tracking_event_and_return for super::RemoteProcedures { input: RuntimeProfileTaskCenterGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_evidence_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_evidence_snapshot_type.rs new file mode 100644 index 00000000..3a6759f8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_evidence_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileFeedbackEvidenceSnapshot { + pub evidence_id: String, + pub file_name: String, + pub content_type: String, + pub size_bytes: u64, + pub data_url: String, +} + +impl __sdk::InModule for RuntimeProfileFeedbackEvidenceSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_status_type.rs new file mode 100644 index 00000000..7ace1eef --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_status_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileFeedbackStatus { + Open, +} + +impl __sdk::InModule for RuntimeProfileFeedbackStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_input_type.rs new file mode 100644 index 00000000..5138f9fe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_feedback_evidence_snapshot_type::RuntimeProfileFeedbackEvidenceSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileFeedbackSubmissionInput { + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_items: Vec, + pub created_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileFeedbackSubmissionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_procedure_result_type.rs new file mode 100644 index 00000000..126e6212 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileFeedbackSubmissionProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileFeedbackSubmissionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_snapshot_type.rs new file mode 100644 index 00000000..2649fab8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_feedback_submission_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileFeedbackSubmissionSnapshot { + pub feedback_id: String, + pub user_id: String, + pub description: String, + pub contact_phone: Option, + pub evidence_json: String, + pub status: RuntimeProfileFeedbackStatus, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileFeedbackSubmissionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs new file mode 100644 index 00000000..208a35a9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_profile_feedback_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput; +use super::runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SubmitProfileFeedbackAndReturnArgs { + pub input: RuntimeProfileFeedbackSubmissionInput, +} + +impl __sdk::InModule for SubmitProfileFeedbackAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `submit_profile_feedback_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait submit_profile_feedback_and_return { + fn submit_profile_feedback_and_return(&self, input: RuntimeProfileFeedbackSubmissionInput) { + self.submit_profile_feedback_and_return_then(input, |_, _| {}); + } + + fn submit_profile_feedback_and_return_then( + &self, + input: RuntimeProfileFeedbackSubmissionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl submit_profile_feedback_and_return for super::RemoteProcedures { + fn submit_profile_feedback_and_return_then( + &self, + input: RuntimeProfileFeedbackSubmissionInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileFeedbackSubmissionProcedureResult>( + "submit_profile_feedback_and_return", + SubmitProfileFeedbackAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 935dc751..d59ed1cd 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -234,6 +234,37 @@ impl SpacetimeClient { .await } + pub async fn submit_profile_feedback( + &self, + user_id: String, + description: String, + contact_phone: Option, + evidence_items: Vec, + created_at_micros: i64, + ) -> Result { + let procedure_input = build_runtime_profile_feedback_submission_input( + user_id, + description, + contact_phone, + evidence_items, + created_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .submit_profile_feedback_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_feedback_submission_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_profile_referral_invite_center( &self, user_id: String, @@ -385,14 +416,15 @@ impl SpacetimeClient { .into(); self.call_after_connect(move |connection, sender| { - connection - .procedures() - .query_analytics_metric_then(procedure_input, move |_, result| { + connection.procedures().query_analytics_metric_then( + procedure_input, + move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_analytics_metric_query_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index b1a82e75..fb115b35 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -181,6 +181,7 @@ macro_rules! migration_tables { public_work_like, profile_membership, profile_recharge_order, + profile_feedback_submission, profile_save_archive, player_progression, chapter_progression, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index e9ff09d5..a995f717 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -351,6 +351,27 @@ pub struct ProfileRechargeOrder { pub(crate) membership_expires_at: Option, } +#[spacetimedb::table( + accessor = profile_feedback_submission, + index(accessor = by_profile_feedback_user_id, btree(columns = [user_id])), + index( + accessor = by_profile_feedback_user_created_at, + btree(columns = [user_id, created_at]) + ) +)] +pub struct ProfileFeedbackSubmission { + #[primary_key] + pub(crate) feedback_id: String, + pub(crate) user_id: String, + pub(crate) description: String, + pub(crate) contact_phone: Option, + // 中文注释:首版凭证以 Data URL 写入私有表,HTTP 回包只返回元数据,后续迁 OSS 不改变外部契约。 + pub(crate) evidence_json: String, + pub(crate) status: RuntimeProfileFeedbackStatus, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + #[spacetimedb::table( accessor = profile_save_archive, index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])), @@ -749,6 +770,25 @@ pub fn create_profile_recharge_order_and_return( } } +#[spacetimedb::procedure] +pub fn submit_profile_feedback_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileFeedbackSubmissionInput, +) -> RuntimeProfileFeedbackSubmissionProcedureResult { + match ctx.try_with_tx(|tx| submit_profile_feedback_record(tx, input.clone())) { + Ok(record) => RuntimeProfileFeedbackSubmissionProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileFeedbackSubmissionProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。 #[spacetimedb::procedure] pub fn get_profile_referral_invite_center( @@ -1906,6 +1946,47 @@ fn create_profile_recharge_order_record( )) } +fn submit_profile_feedback_record( + ctx: &ReducerContext, + input: RuntimeProfileFeedbackSubmissionInput, +) -> Result { + let validated_input = build_runtime_profile_feedback_submission_input( + input.user_id, + input.description, + input.contact_phone, + input.evidence_items, + input.created_at_micros, + ) + .map_err(|error| error.to_string())?; + let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros); + let feedback_id = build_runtime_profile_feedback_submission_id( + &validated_input.user_id, + validated_input.created_at_micros, + ); + let evidence_json = serde_json::to_string(&validated_input.evidence_items) + .map_err(|error| format!("反馈凭证序列化失败: {error}"))?; + let row = ProfileFeedbackSubmission { + feedback_id: feedback_id.clone(), + user_id: validated_input.user_id, + description: validated_input.description, + contact_phone: validated_input.contact_phone, + evidence_json, + status: RuntimeProfileFeedbackStatus::Open, + created_at, + updated_at: created_at, + }; + ctx.db.profile_feedback_submission().insert(row); + + let latest = ctx + .db + .profile_feedback_submission() + .feedback_id() + .find(&feedback_id) + .ok_or_else(|| "profile_feedback_submission 写入后未能读取".to_string())?; + + Ok(build_profile_feedback_submission_snapshot_from_row(&latest)) +} + fn get_profile_referral_invite_center_snapshot( ctx: &ReducerContext, input: RuntimeReferralInviteCenterGetInput, @@ -3440,6 +3521,21 @@ fn build_profile_recharge_order_snapshot_from_row( } } +fn build_profile_feedback_submission_snapshot_from_row( + row: &ProfileFeedbackSubmission, +) -> RuntimeProfileFeedbackSubmissionSnapshot { + RuntimeProfileFeedbackSubmissionSnapshot { + feedback_id: row.feedback_id.clone(), + user_id: row.user_id.clone(), + description: row.description.clone(), + contact_phone: row.contact_phone.clone(), + evidence_json: row.evidence_json.clone(), + status: row.status, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_played_world_snapshot_from_row( row: &ProfilePlayedWorld, ) -> RuntimeProfilePlayedWorldSnapshot { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e00e70e4..79441d3b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -185,7 +185,10 @@ import { recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; +import { + getRpgProfilePlayStats, + submitRpgProfileFeedback, +} from '../../services/rpg-entry/rpgProfileClient'; import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; import { squareHoleCreationClient } from '../../services/square-hole-creation'; import { @@ -5510,6 +5513,7 @@ export function PlatformEntryFlowShellImpl({ setPlatformTab('profile'); setSelectionStage('platform'); }} + onSubmit={submitRpgProfileFeedback} /> )} diff --git a/src/components/platform-entry/PlatformFeedbackView.test.tsx b/src/components/platform-entry/PlatformFeedbackView.test.tsx index 3f998af3..99571bd6 100644 --- a/src/components/platform-entry/PlatformFeedbackView.test.tsx +++ b/src/components/platform-entry/PlatformFeedbackView.test.tsx @@ -1,10 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +class MockFileReader { + result: string | ArrayBuffer | null = null; + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + + readAsDataURL(file: File) { + this.result = `data:${file.type};base64,ZmVlZGJhY2s=`; + this.onload?.(); + } +} + +beforeEach(() => { + vi.stubGlobal('FileReader', MockFileReader); +}); + test('PlatformFeedbackView renders reference feedback fields', () => { render(); @@ -48,11 +63,45 @@ test('PlatformFeedbackView submits trimmed payload', async () => { expect(onSubmit).toHaveBeenCalledWith({ description: '这个反馈页面无法正常上传图片', contactPhone: '13800000000', - evidenceFiles: [], + evidenceItems: [], }); await waitFor(() => expect(screen.getByText('反馈已提交')).toBeTruthy()); }); +test('PlatformFeedbackView previews image data urls and submits evidence items', async () => { + const onSubmit = vi.fn(); + render(); + + const file = new File(['feedback'], 'preview.png', { type: 'image/png' }); + fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { + target: { files: [file] }, + }); + + const preview = await screen.findByAltText('反馈凭证预览'); + expect(preview.getAttribute('src')).toBe( + 'data:image/png;base64,ZmVlZGJhY2s=', + ); + + fireEvent.change(screen.getByLabelText('问题描述'), { + target: { value: '图片上传后现在应该展示预览' }, + }); + fireEvent.click(screen.getByRole('button', { name: '提交' })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ + description: '图片上传后现在应该展示预览', + contactPhone: null, + evidenceItems: [ + { + fileName: 'preview.png', + contentType: 'image/png', + sizeBytes: file.size, + dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=', + }, + ], + }); +}); + test('PlatformFeedbackView calls back from header home button', () => { const onBack = vi.fn(); render(); diff --git a/src/components/platform-entry/PlatformFeedbackView.tsx b/src/components/platform-entry/PlatformFeedbackView.tsx index 231cd449..2d71eafc 100644 --- a/src/components/platform-entry/PlatformFeedbackView.tsx +++ b/src/components/platform-entry/PlatformFeedbackView.tsx @@ -1,32 +1,53 @@ -import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowLeft, CheckCircle2, ImagePlus, Send, X } from 'lucide-react'; +import { useRef, useState } from 'react'; + +import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime'; const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10; const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200; const MAX_FEEDBACK_EVIDENCE_COUNT = 4; const MAX_CONTACT_PHONE_LENGTH = 40; +const MAX_FEEDBACK_EVIDENCE_BYTES = 1_048_576; +const MAX_FEEDBACK_EVIDENCE_TOTAL_BYTES = 4_194_304; export type PlatformFeedbackPayload = { description: string; - contactPhone: string; - evidenceFiles: File[]; + contactPhone?: string | null; + evidenceItems: ProfileFeedbackEvidenceItemInput[]; }; export type PlatformFeedbackViewProps = { onBack: () => void; - onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise; + onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise; }; type EvidencePreview = { id: string; - file: File; - url: string; + fileName: string; + contentType: string; + sizeBytes: number; + dataUrl: string; }; function buildEvidencePreviewId(file: File, index: number) { return `${file.name}:${file.size}:${file.lastModified}:${index}`; } +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('图片读取失败')); + } + }; + reader.onerror = () => reject(new Error('图片读取失败')); + reader.readAsDataURL(file); + }); +} + export function PlatformFeedbackView({ onBack, onSubmit, @@ -41,17 +62,6 @@ export function PlatformFeedbackView({ const [submitted, setSubmitted] = useState(false); const descriptionLength = description.length; - const evidenceFiles = useMemo( - () => evidencePreviews.map((preview) => preview.file), - [evidencePreviews], - ); - - useEffect( - () => () => { - evidencePreviews.forEach((preview) => URL.revokeObjectURL(preview.url)); - }, - [evidencePreviews], - ); const showTemporaryNotice = (message: string) => { setNotice(message); @@ -75,16 +85,18 @@ export function PlatformFeedbackView({ }; const addEvidenceFiles = (files: FileList | null) => { + const selectedFiles = files ? Array.from(files) : []; if (evidenceInputRef.current) { evidenceInputRef.current.value = ''; } - if (!files || files.length === 0) { + if (selectedFiles.length === 0) { return; } - const selectedFiles = Array.from(files).filter((file) => - file.type.startsWith('image/'), - ); + if (selectedFiles.some((file) => !file.type.startsWith('image/'))) { + setError('反馈凭证只支持图片类型'); + return; + } const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length; if (remainingCount <= 0 || selectedFiles.length > remainingCount) { setError('最多上传四张凭证'); @@ -95,23 +107,49 @@ export function PlatformFeedbackView({ return; } - const nextPreviews = nextFiles.map((file, index) => ({ - id: buildEvidencePreviewId(file, evidencePreviews.length + index), - file, - url: URL.createObjectURL(file), - })); - setEvidencePreviews((currentPreviews) => [...currentPreviews, ...nextPreviews]); - setSubmitted(false); + if (nextFiles.some((file) => file.size > MAX_FEEDBACK_EVIDENCE_BYTES)) { + setError('单张反馈凭证不能超过 1MB'); + return; + } + const currentTotalBytes = evidencePreviews.reduce( + (total, preview) => total + preview.sizeBytes, + 0, + ); + const nextTotalBytes = nextFiles.reduce( + (total, file) => total + file.size, + currentTotalBytes, + ); + if (nextTotalBytes > MAX_FEEDBACK_EVIDENCE_TOTAL_BYTES) { + setError('反馈凭证总大小不能超过 4MB'); + return; + } + + Promise.all( + nextFiles.map(async (file, index) => ({ + id: buildEvidencePreviewId(file, evidencePreviews.length + index), + fileName: file.name, + contentType: file.type, + sizeBytes: file.size, + dataUrl: await readFileAsDataUrl(file), + })), + ) + .then((nextPreviews) => { + setEvidencePreviews((currentPreviews) => [ + ...currentPreviews, + ...nextPreviews, + ]); + setError(null); + setSubmitted(false); + }) + .catch((readError: unknown) => { + setError(readError instanceof Error ? readError.message : '图片读取失败'); + }); }; const removeEvidencePreview = (id: string) => { - setEvidencePreviews((currentPreviews) => { - const previewToRemove = currentPreviews.find((preview) => preview.id === id); - if (previewToRemove) { - URL.revokeObjectURL(previewToRemove.url); - } - return currentPreviews.filter((preview) => preview.id !== id); - }); + setEvidencePreviews((currentPreviews) => + currentPreviews.filter((preview) => preview.id !== id), + ); setError(null); setSubmitted(false); }; @@ -138,7 +176,7 @@ export function PlatformFeedbackView({ setSubmitted(false); return; } - if (evidenceFiles.length > MAX_FEEDBACK_EVIDENCE_COUNT) { + if (evidencePreviews.length > MAX_FEEDBACK_EVIDENCE_COUNT) { setError('最多上传四张凭证'); setSubmitted(false); return; @@ -146,12 +184,16 @@ export function PlatformFeedbackView({ setIsSubmitting(true); setError(null); - // 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。 void Promise.resolve( onSubmit?.({ description: trimmedDescription, - contactPhone: trimmedContactPhone, - evidenceFiles, + contactPhone: trimmedContactPhone || null, + evidenceItems: evidencePreviews.map((preview) => ({ + fileName: preview.fileName, + contentType: preview.contentType, + sizeBytes: preview.sizeBytes, + dataUrl: preview.dataUrl, + })), }), ) .then(() => setSubmitted(true)) @@ -163,38 +205,32 @@ export function PlatformFeedbackView({ }; return ( -
-
-
-
- -
- 帮助与反馈 -
- +
+
+
+ +
+ 帮助与反馈
-
-
反馈问题
+
+
+ 反馈问题 +
-
-