Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -32,6 +32,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_*`。
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
||||
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
||||
|
||||
## 反馈页清空 file input 前必须先拷贝 FileList
|
||||
|
||||
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
|
||||
- 原因:浏览器传入的 `FileList` 可能跟 `<input type="file">` 保持 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 图片生成密钥未配置`。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<String>`
|
||||
- `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 认证测试通过。
|
||||
@@ -7,6 +7,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 差异。
|
||||
- [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。
|
||||
- [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。
|
||||
- [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 统一通过环境变量注入。
|
||||
- [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md):冻结基于 LangChain-Rust 的创意互动内容生成 Agent 技术方案,明确首版只支持拼图模板、必须显式展示模板选择和积分范围,通过拼图模块 Tool/模板协议填充同一份草稿字段,支持单关卡与多关卡图片生成、立即试玩、表单化编辑和 Agent 自然语言修订草稿字段。
|
||||
- [VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md](./VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md):记录视觉小说模板 `VN-03` Prompt / LLM 工具落地,包含创作底稿 Prompt、运行时 GM Prompt、repair Prompt、工具参数 schema、Responses 请求口径和定向验证结果。
|
||||
|
||||
@@ -24,7 +24,7 @@ spacetime sql <db> "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` |
|
||||
@@ -334,6 +334,18 @@ SELECT * FROM profile_recharge_order WHERE order_id = '<order_id>';
|
||||
SELECT * FROM profile_recharge_order WHERE user_id = '<user_id>' ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
### `profile_feedback_submission`
|
||||
|
||||
- 作用:保存“我的”页签帮助与反馈提交记录,关联当前登录用户的问题描述、选填联系电话和图片凭证快照。
|
||||
- 结构:`feedback_id PK: String`, `user_id: String`, `description: String`, `contact_phone: Option<String>`, `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 = '<feedback_id>';
|
||||
SELECT * FROM profile_feedback_submission WHERE user_id = '<user_id>' ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
### `profile_save_archive`
|
||||
|
||||
- 作用:用户存档列表,保存世界信息、封面、当前状态 JSON 和剧情 JSON。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,9 +38,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';
|
||||
|
||||
@@ -566,7 +566,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")
|
||||
|
||||
@@ -121,7 +121,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,
|
||||
@@ -165,6 +165,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 {
|
||||
@@ -1278,6 +1279,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(
|
||||
|
||||
@@ -107,7 +107,7 @@ fn main() -> Result<(), io::Error> {
|
||||
|
||||
async fn run_server() -> Result<(), io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
|
||||
// 只尊重外层 shell 先注入的变量;.env.local 需要能覆盖 .env。
|
||||
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
|
||||
load_local_env_files();
|
||||
|
||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
||||
|
||||
@@ -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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<SubmitProfileFeedbackRequest>,
|
||||
) -> Result<Json<Value>, 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<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -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",
|
||||
|
||||
@@ -164,6 +164,36 @@ pub fn build_runtime_profile_recharge_order_record(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_feedback_submission_record(
|
||||
snapshot: RuntimeProfileFeedbackSubmissionSnapshot,
|
||||
) -> Result<RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileFieldError> {
|
||||
let evidence_items = serde_json::from_str::<Vec<RuntimeProfileFeedbackEvidenceSnapshot>>(
|
||||
&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 {
|
||||
|
||||
@@ -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<String>,
|
||||
evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
|
||||
created_at_micros: i64,
|
||||
) -> Result<RuntimeProfileFeedbackSubmissionInput, RuntimeProfileFieldError> {
|
||||
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<RuntimeReferralInviteCenterGetInput, RuntimeProfileFieldError> {
|
||||
@@ -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<i64> {
|
||||
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<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
|
||||
@@ -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<String>,
|
||||
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
|
||||
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<String>,
|
||||
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<RuntimeProfileFeedbackSubmissionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceRecord>,
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub evidence_items: Vec<ProfileFeedbackEvidenceItemRequest>,
|
||||
}
|
||||
|
||||
#[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<ProfileFeedbackEvidenceItemResponse>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -154,8 +154,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,
|
||||
@@ -167,6 +168,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,
|
||||
|
||||
@@ -161,6 +161,34 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileFeedbackSubmissionInput>
|
||||
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<module_runtime::RuntimeProfileFeedbackEvidenceSnapshot>
|
||||
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<module_runtime::RuntimeProfileRewardCodeRedeemInput>
|
||||
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<RuntimeProfileFeedbackSubmissionRecord, SpacetimeClientError> {
|
||||
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<RuntimeReferralInviteCenterRecord, SpacetimeClientError> {
|
||||
@@ -2110,6 +2155,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 {
|
||||
@@ -4877,6 +4937,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,
|
||||
|
||||
@@ -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 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
// 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};
|
||||
@@ -409,6 +409,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;
|
||||
@@ -579,6 +581,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;
|
||||
@@ -655,8 +662,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;
|
||||
@@ -716,6 +723,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;
|
||||
@@ -1195,6 +1203,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::*;
|
||||
@@ -1365,6 +1375,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;
|
||||
@@ -1441,8 +1456,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;
|
||||
@@ -1502,6 +1517,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;
|
||||
@@ -1885,6 +1901,7 @@ pub struct DbUpdate {
|
||||
npc_state: __sdk::TableUpdate<NpcState>,
|
||||
player_progression: __sdk::TableUpdate<PlayerProgression>,
|
||||
profile_dashboard_state: __sdk::TableUpdate<ProfileDashboardState>,
|
||||
profile_feedback_submission: __sdk::TableUpdate<ProfileFeedbackSubmission>,
|
||||
profile_invite_code: __sdk::TableUpdate<ProfileInviteCode>,
|
||||
profile_membership: __sdk::TableUpdate<ProfileMembership>,
|
||||
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
|
||||
@@ -2042,6 +2059,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)?),
|
||||
@@ -2367,6 +2387,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::<ProfileFeedbackSubmission>(
|
||||
"profile_feedback_submission",
|
||||
&self.profile_feedback_submission,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.feedback_id);
|
||||
diff.profile_invite_code = cache
|
||||
.apply_diff_to_table::<ProfileInviteCode>(
|
||||
"profile_invite_code",
|
||||
@@ -2688,6 +2714,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)?),
|
||||
@@ -2932,6 +2961,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)?),
|
||||
@@ -3108,6 +3140,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>,
|
||||
@@ -3327,6 +3360,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.profile_dashboard_state,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<ProfileFeedbackSubmission>(
|
||||
"profile_feedback_submission",
|
||||
&self.profile_feedback_submission,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<ProfileInviteCode>(
|
||||
"profile_invite_code",
|
||||
&self.profile_invite_code,
|
||||
@@ -4224,6 +4262,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);
|
||||
@@ -4303,6 +4342,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"npc_state",
|
||||
"player_progression",
|
||||
"profile_dashboard_state",
|
||||
"profile_feedback_submission",
|
||||
"profile_invite_code",
|
||||
"profile_membership",
|
||||
"profile_played_world",
|
||||
|
||||
@@ -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<ProfileFeedbackSubmission>,
|
||||
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::<ProfileFeedbackSubmission>("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<Item = ProfileFeedbackSubmission> + '_ {
|
||||
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<ProfileFeedbackSubmission, String>,
|
||||
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::<String>("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<ProfileFeedbackSubmission> {
|
||||
self.imp.find(col_val)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table =
|
||||
client_cache.get_or_make_table::<ProfileFeedbackSubmission>("profile_feedback_submission");
|
||||
_table.add_unique_constraint::<String>("feedback_id", |row| &row.feedback_id);
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<ProfileFeedbackSubmission>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<ProfileFeedbackSubmission>", "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<ProfileFeedbackSubmission>;
|
||||
}
|
||||
|
||||
impl profile_feedback_submissionQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn profile_feedback_submission(
|
||||
&self,
|
||||
) -> __sdk::__query_builder::Table<ProfileFeedbackSubmission> {
|
||||
__sdk::__query_builder::Table::new("profile_feedback_submission")
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<ProfileFeedbackSubmission, String>,
|
||||
pub user_id: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
|
||||
pub description: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
|
||||
pub contact_phone: __sdk::__query_builder::Col<ProfileFeedbackSubmission, Option<String>>,
|
||||
pub evidence_json: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
|
||||
pub status:
|
||||
__sdk::__query_builder::Col<ProfileFeedbackSubmission, RuntimeProfileFeedbackStatus>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileFeedbackSubmission, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<ProfileFeedbackSubmission, __sdk::Timestamp>,
|
||||
}
|
||||
|
||||
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<ProfileFeedbackSubmission, String>,
|
||||
pub user_id: __sdk::__query_builder::IxCol<ProfileFeedbackSubmission, String>,
|
||||
}
|
||||
|
||||
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 {}
|
||||
@@ -34,10 +34,10 @@ pub trait record_daily_login_tracking_event_and_return {
|
||||
input: RuntimeProfileTaskCenterGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + 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<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileFeedbackSubmissionInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -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<RuntimeProfileFeedbackSubmissionSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileFeedbackSubmissionProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -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<String>,
|
||||
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;
|
||||
}
|
||||
@@ -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<RuntimeProfileFeedbackSubmissionProcedureResult, __sdk::InternalError>,
|
||||
) + 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<RuntimeProfileFeedbackSubmissionProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileFeedbackSubmissionProcedureResult>(
|
||||
"submit_profile_feedback_and_return",
|
||||
SubmitProfileFeedbackAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,37 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
&self,
|
||||
user_id: String,
|
||||
description: String,
|
||||
contact_phone: Option<String>,
|
||||
evidence_items: Vec<module_runtime::RuntimeProfileFeedbackEvidenceSnapshot>,
|
||||
created_at_micros: i64,
|
||||
) -> Result<RuntimeProfileFeedbackSubmissionRecord, SpacetimeClientError> {
|
||||
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,
|
||||
|
||||
@@ -185,6 +185,7 @@ macro_rules! migration_tables {
|
||||
public_work_like,
|
||||
profile_membership,
|
||||
profile_recharge_order,
|
||||
profile_feedback_submission,
|
||||
profile_save_archive,
|
||||
player_progression,
|
||||
chapter_progression,
|
||||
|
||||
@@ -351,6 +351,27 @@ pub struct ProfileRechargeOrder {
|
||||
pub(crate) membership_expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
// 中文注释:首版凭证以 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<RuntimeProfileFeedbackSubmissionSnapshot, String> {
|
||||
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 {
|
||||
|
||||
@@ -220,7 +220,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 {
|
||||
@@ -7494,6 +7497,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPlatformTab('profile');
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onSubmit={submitRpgProfileFeedback}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -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(<PlatformFeedbackView onBack={vi.fn()} />);
|
||||
|
||||
@@ -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(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
|
||||
|
||||
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(<PlatformFeedbackView onBack={onBack} />);
|
||||
|
||||
@@ -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<void>;
|
||||
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
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<string>((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 (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[#f5f6f8] text-[#202124]">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col pb-8">
|
||||
<header className="sticky top-0 z-10 rounded-b-[1.35rem] bg-white px-4 pb-3 pt-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_5.75rem] items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回我的页签"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[#24262b] transition hover:bg-slate-100"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="text-center text-base font-semibold text-[#1f2329]">
|
||||
帮助与反馈
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 items-center justify-center gap-2 rounded-full border border-[#d8dce3] px-2 text-[#1f2329]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-lg leading-none">···</span>
|
||||
<span className="h-4 w-px bg-[#d8dce3]" />
|
||||
<span className="h-0.5 w-3 rounded-full bg-[#1f2329]" />
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#1f2329]" />
|
||||
</div>
|
||||
<div className="platform-page-stage platform-remap-surface min-h-0 flex-1 overflow-y-auto text-[var(--platform-text-strong)]">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col px-4 pb-6 pt-4">
|
||||
<header className="flex shrink-0 items-center gap-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回我的页签"
|
||||
className="platform-button platform-button--ghost h-10 w-10 shrink-0 justify-center rounded-full p-0"
|
||||
>
|
||||
<ArrowLeft className="h-[1.125rem] w-[1.125rem]" />
|
||||
</button>
|
||||
<div className="min-w-0 text-base font-black text-[var(--platform-text-strong)]">
|
||||
帮助与反馈
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col gap-3 px-4 pt-5">
|
||||
<div className="text-sm font-medium text-[#8b93a1]">反馈问题</div>
|
||||
<main className="flex flex-1 flex-col gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
反馈问题
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<label htmlFor="profile-feedback-description" className="block text-base font-semibold text-[#1f2329]">
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<label
|
||||
htmlFor="profile-feedback-description"
|
||||
className="block text-base font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
问题描述
|
||||
</label>
|
||||
<textarea
|
||||
@@ -203,25 +239,25 @@ export function PlatformFeedbackView({
|
||||
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
|
||||
onChange={(event) => updateDescription(event.target.value)}
|
||||
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助,温馨提醒您请勿填写身份证号等个人隐私信息。"
|
||||
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
|
||||
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<div className="text-right text-xs text-[#a5adba]">
|
||||
<div className="text-right text-xs text-[var(--platform-text-soft)]">
|
||||
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<div className="text-base font-semibold text-[#1f2329]">
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
上传凭证(提供问题截图)
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
{evidencePreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.id}
|
||||
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[#e3e6eb] bg-[#f7f8fa]"
|
||||
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)]"
|
||||
>
|
||||
<img
|
||||
src={preview.url}
|
||||
src={preview.dataUrl}
|
||||
alt="反馈凭证预览"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -239,7 +275,7 @@ export function PlatformFeedbackView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEvidencePicker}
|
||||
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[#cdd3dc] bg-[#fbfcfd] text-[#9aa3af] transition hover:border-[#2f7cf6] hover:text-[#2f7cf6]"
|
||||
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)] text-[var(--platform-text-soft)] transition hover:border-[var(--platform-surface-hover-border)] hover:text-[var(--platform-text-strong)]"
|
||||
>
|
||||
<ImagePlus className="h-6 w-6" />
|
||||
<span className="mt-2 text-xs font-medium">上传凭证</span>
|
||||
@@ -257,8 +293,11 @@ export function PlatformFeedbackView({
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
|
||||
<label htmlFor="profile-feedback-phone" className="block text-base font-semibold text-[#1f2329]">
|
||||
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
|
||||
<label
|
||||
htmlFor="profile-feedback-phone"
|
||||
className="block text-base font-semibold text-[var(--platform-text-strong)]"
|
||||
>
|
||||
联系电话
|
||||
</label>
|
||||
<input
|
||||
@@ -268,23 +307,23 @@ export function PlatformFeedbackView({
|
||||
maxLength={MAX_CONTACT_PHONE_LENGTH}
|
||||
onChange={(event) => updateContactPhone(event.target.value)}
|
||||
placeholder="选填,如您填写则将会同步开发者与您联系"
|
||||
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
|
||||
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-600">
|
||||
<div className="rounded-[1.2rem] border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm font-medium text-[var(--platform-button-danger-text)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{submitted ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
<div className="flex items-center gap-2 rounded-[1.2rem] border border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-success-text)]">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
反馈已提交
|
||||
</div>
|
||||
) : null}
|
||||
{notice ? (
|
||||
<div className="rounded-2xl bg-blue-50 px-4 py-3 text-sm font-medium text-[#2f7cf6]">
|
||||
<div className="rounded-[1.2rem] border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-cool-text)]">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -293,7 +332,7 @@ export function PlatformFeedbackView({
|
||||
type="button"
|
||||
onClick={submitFeedback}
|
||||
disabled={isSubmitting}
|
||||
className="mt-2 flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-[#2f7cf6] text-base font-semibold text-white shadow-[0_10px_22px_rgba(47,124,246,0.26)] transition hover:bg-[#1f6bea] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="platform-button platform-button--primary mt-2 h-12 w-full justify-center text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isSubmitting ? '提交中' : '提交'}
|
||||
@@ -302,19 +341,10 @@ export function PlatformFeedbackView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
|
||||
className="self-center px-3 py-2 text-sm font-medium text-[#2f7cf6]"
|
||||
className="self-center px-3 py-2 text-sm font-semibold text-[var(--platform-cool-text)]"
|
||||
>
|
||||
查看反馈与投诉记录
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="mt-auto flex items-center justify-center gap-2 self-center px-3 py-2 text-xs font-medium text-[#8b93a1]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回我的
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
submitRpgProfileFeedback,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
@@ -156,3 +157,59 @@ describe('rpgProfileClient save archive routes', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient feedback routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
feedback: {
|
||||
feedbackId: 'feedback:user-1:1',
|
||||
status: 'open',
|
||||
createdAt: '2026-05-08T10:00:00Z',
|
||||
evidenceItems: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('submits profile feedback through the profile route', async () => {
|
||||
await submitRpgProfileFeedback({
|
||||
description: '图片上传后没有展示预览',
|
||||
contactPhone: null,
|
||||
evidenceItems: [
|
||||
{
|
||||
fileName: 'preview.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: 128,
|
||||
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/profile/feedback',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'提交反馈失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(requestJsonMock.mock.calls[0][1].body)).toEqual({
|
||||
description: '图片上传后没有展示预览',
|
||||
contactPhone: null,
|
||||
evidenceItems: [
|
||||
{
|
||||
fileName: 'preview.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: 128,
|
||||
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
RuntimeSettings,
|
||||
SubmitProfileFeedbackRequest,
|
||||
SubmitProfileFeedbackResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -101,6 +103,22 @@ export function createRpgProfileRechargeOrder(
|
||||
);
|
||||
}
|
||||
|
||||
export function submitRpgProfileFeedback(
|
||||
payload: SubmitProfileFeedbackRequest,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<SubmitProfileFeedbackResponse>(
|
||||
'/profile/feedback',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交反馈失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileReferralInviteCenter(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
@@ -276,6 +294,7 @@ export const rpgProfileClient = {
|
||||
getWalletLedger: getRpgProfileWalletLedger,
|
||||
getRechargeCenter: getRpgProfileRechargeCenter,
|
||||
createRechargeOrder: createRpgProfileRechargeOrder,
|
||||
submitFeedback: submitRpgProfileFeedback,
|
||||
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
|
||||
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
|
||||
getTasks: getRpgProfileTasks,
|
||||
|
||||
Reference in New Issue
Block a user