Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-08 22:12:10 +08:00
38 changed files with 1515 additions and 135 deletions

View File

@@ -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

View File

@@ -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_*`

View File

@@ -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 图片生成密钥未配置`

View File

@@ -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`

View File

@@ -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 认证测试通过。

View File

@@ -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 请求口径和定向验证结果。

View File

@@ -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。

View File

@@ -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;

View File

@@ -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';

View File

@@ -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")

View File

@@ -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(

View File

@@ -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();
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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
),
}
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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")
}
}

View File

@@ -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 {}

View File

@@ -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>(

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_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;
}

View File

@@ -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;
}

View File

@@ -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,
);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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>
)}

View File

@@ -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} />);

View File

@@ -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>

View File

@@ -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=',
},
],
});
});
});

View File

@@ -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,