Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
# Conflicts: # .hermes/shared-memory/decision-log.md # .hermes/shared-memory/project-overview.md # docs/【开发运维】本地开发验证与生产运维-2026-05-15.md # scripts/dev.test.ts # server-rs/crates/api-server/src/creation_entry_config.rs # server-rs/crates/api-server/src/wooden_fish.rs # server-rs/crates/module-auth/src/lib.rs # server-rs/crates/spacetime-client/src/wooden_fish.rs # server-rs/crates/spacetime-module/src/auth/procedures.rs # src/components/custom-world-home/creationWorkShelf.ts # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/rpgEntryWorldPresentation.ts # src/services/miniGameDraftGenerationProgress.test.ts # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::creation_entry_config::{CreationEntryEventBannerResponse, UnifiedCreationSpecResponse};
|
||||
|
||||
// 管理后台协议统一收口在 shared-contracts,避免页面脚本和 Rust handler 各自手拼字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -16,6 +18,8 @@ pub struct AdminLoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminCreationEntryConfigResponse {
|
||||
pub entries: Vec<AdminCreationEntryTypeConfigPayload>,
|
||||
/// 底部加号创作入口页的后台公告列表。
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
}
|
||||
|
||||
/// 后台单个创作入口开关配置。
|
||||
@@ -34,6 +38,8 @@ pub struct AdminCreationEntryTypeConfigPayload {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口开关配置请求。
|
||||
@@ -51,6 +57,16 @@ pub struct AdminUpsertCreationEntryTypeConfigRequest {
|
||||
pub category_id: String,
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 后台保存创作入口公告表单序列化结果请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertCreationEntryEventBannersRequest {
|
||||
/// 传输字段沿用既有契约,内容由后台标题 / HTML 表单生成。
|
||||
pub event_banners_json: String,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 前台创作入口配置响应。
|
||||
///
|
||||
/// `event_banner` 保留单条旧契约兼容;新创作入口公告位应优先读取
|
||||
/// `event_banners`,由后台表单配置多条公告并支持轮播。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryConfigResponse {
|
||||
pub start_card: CreationEntryStartCardResponse,
|
||||
pub type_modal: CreationEntryTypeModalResponse,
|
||||
pub event_banner: CreationEntryEventBannerResponse,
|
||||
pub event_banners: Vec<CreationEntryEventBannerResponse>,
|
||||
pub creation_types: Vec<CreationEntryTypeResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口起始卡片文案契约,保留给旧入口卡片兼容使用。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryStartCardResponse {
|
||||
pub title: String,
|
||||
@@ -18,14 +25,19 @@ pub struct CreationEntryStartCardResponse {
|
||||
pub busy_badge: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作类型选择弹层的基础文案契约。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeModalResponse {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 创作入口单条公告。
|
||||
///
|
||||
/// `html_code` 是后台公告代码的主格式,只允许以前端沙箱 iframe 展示;
|
||||
/// 结构化字段仅保留旧数据兼容,不能作为可执行 JSX 或非受控 DOM 注入。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryEventBannerResponse {
|
||||
pub title: String,
|
||||
@@ -34,9 +46,19 @@ pub struct CreationEntryEventBannerResponse {
|
||||
pub prize_pool_mud_points: u64,
|
||||
pub starts_at_text: String,
|
||||
pub ends_at_text: String,
|
||||
#[serde(default = "default_creation_entry_event_banner_render_mode")]
|
||||
pub render_mode: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub html_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// 默认渲染模式使用受控结构化 UI,用于旧数据兼容。
|
||||
pub fn default_creation_entry_event_banner_render_mode() -> String {
|
||||
"structured".to_string()
|
||||
}
|
||||
|
||||
/// 单个创作模板入口配置,决定底部加号入口中的分类、排序和开放状态。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreationEntryTypeResponse {
|
||||
pub id: String,
|
||||
@@ -51,4 +73,383 @@ pub struct CreationEntryTypeResponse {
|
||||
pub category_label: String,
|
||||
pub category_sort_order: i32,
|
||||
pub updated_at_micros: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unified_creation_spec: Option<UnifiedCreationSpecResponse>,
|
||||
}
|
||||
|
||||
/// 统一创作工作台契约,把玩法入口连接到工作台、生成页和结果页阶段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationSpecResponse {
|
||||
pub play_id: String,
|
||||
pub title: String,
|
||||
pub workspace_stage: String,
|
||||
pub generation_stage: String,
|
||||
pub result_stage: String,
|
||||
pub fields: Vec<UnifiedCreationFieldResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnifiedCreationFieldResponse {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub const UNIFIED_CREATION_FIELD_KINDS: [&str; 4] = ["text", "select", "image", "audio"];
|
||||
|
||||
pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreationSpecResponse> {
|
||||
let (workspace_stage, generation_stage, result_stage, fields) = match play_id {
|
||||
"rpg" => (
|
||||
"agent-workspace",
|
||||
"custom-world-generating",
|
||||
"custom-world-result",
|
||||
vec![unified_creation_field("message", "text", "创作想法", true)],
|
||||
),
|
||||
"big-fish" => (
|
||||
"big-fish-agent-workspace",
|
||||
"big-fish-generating",
|
||||
"big-fish-result",
|
||||
vec![unified_creation_field("message", "text", "玩法想法", true)],
|
||||
),
|
||||
"puzzle" => (
|
||||
"puzzle-agent-workspace",
|
||||
"puzzle-generating",
|
||||
"puzzle-result",
|
||||
vec![
|
||||
unified_creation_field("pictureDescription", "text", "画面描述", true),
|
||||
unified_creation_field("referenceImage", "image", "拼图画面", false),
|
||||
unified_creation_field("promptReferenceImages", "image", "参考图", false),
|
||||
],
|
||||
),
|
||||
"match3d" => (
|
||||
"match3d-agent-workspace",
|
||||
"match3d-generating",
|
||||
"match3d-result",
|
||||
vec![
|
||||
unified_creation_field("themeText", "text", "题材", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
],
|
||||
),
|
||||
"jump-hop" => (
|
||||
"jump-hop-workspace",
|
||||
"jump-hop-generating",
|
||||
"jump-hop-result",
|
||||
vec![
|
||||
unified_creation_field("workTitle", "text", "作品标题", true),
|
||||
unified_creation_field("workDescription", "text", "作品简介", true),
|
||||
unified_creation_field("themeTags", "text", "主题标签", true),
|
||||
unified_creation_field("difficulty", "select", "难度", true),
|
||||
unified_creation_field("stylePreset", "select", "风格", true),
|
||||
unified_creation_field("characterPrompt", "text", "角色提示词", true),
|
||||
unified_creation_field("tilePrompt", "text", "地块提示词", true),
|
||||
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
|
||||
],
|
||||
),
|
||||
"wooden-fish" => (
|
||||
"wooden-fish-workspace",
|
||||
"wooden-fish-generating",
|
||||
"wooden-fish-result",
|
||||
vec![
|
||||
unified_creation_field("hitObjectPrompt", "text", "敲什么", false),
|
||||
unified_creation_field("hitObjectReferenceImage", "image", "参考图", false),
|
||||
unified_creation_field("hitSoundAsset", "audio", "敲击音效", false),
|
||||
unified_creation_field("floatingWords", "text", "功德有什么", true),
|
||||
],
|
||||
),
|
||||
"square-hole" => (
|
||||
"square-hole-agent-workspace",
|
||||
"square-hole-generating",
|
||||
"square-hole-result",
|
||||
vec![unified_creation_field("message", "text", "玩法想法", true)],
|
||||
),
|
||||
"bark-battle" => (
|
||||
"bark-battle-workspace",
|
||||
"bark-battle-generating",
|
||||
"bark-battle-result",
|
||||
vec![
|
||||
unified_creation_field("title", "text", "作品标题", true),
|
||||
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
|
||||
unified_creation_field(
|
||||
"playerImageDescription",
|
||||
"text",
|
||||
"玩家形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field(
|
||||
"opponentImageDescription",
|
||||
"text",
|
||||
"对手形象描述",
|
||||
true,
|
||||
),
|
||||
unified_creation_field("onomatopoeia", "text", "拟声词", false),
|
||||
unified_creation_field("difficultyPreset", "select", "难度", true),
|
||||
],
|
||||
),
|
||||
"visual-novel" => (
|
||||
"visual-novel-agent-workspace",
|
||||
"visual-novel-generating",
|
||||
"visual-novel-result",
|
||||
vec![
|
||||
unified_creation_field("ideaText", "text", "一句话创作", true),
|
||||
unified_creation_field("visualStyleId", "select", "视觉画风", true),
|
||||
],
|
||||
),
|
||||
"baby-object-match" => (
|
||||
"baby-object-match-workspace",
|
||||
"baby-object-match-generating",
|
||||
"baby-object-match-result",
|
||||
vec![
|
||||
unified_creation_field("itemAName", "text", "物品 A", true),
|
||||
unified_creation_field("itemBName", "text", "物品 B", true),
|
||||
],
|
||||
),
|
||||
"creative-agent" => (
|
||||
"creative-agent-workspace",
|
||||
"puzzle-generating",
|
||||
"puzzle-result",
|
||||
vec![
|
||||
unified_creation_field("message", "text", "创作想法", true),
|
||||
unified_creation_field("referenceImage", "image", "参考图", false),
|
||||
],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(UnifiedCreationSpecResponse {
|
||||
play_id: play_id.to_string(),
|
||||
title: "想做个什么玩法?".to_string(),
|
||||
workspace_stage: workspace_stage.to_string(),
|
||||
generation_stage: generation_stage.to_string(),
|
||||
result_stage: result_stage.to_string(),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim().is_empty() {
|
||||
return Err("统一创作契约 playId 不能为空".to_string());
|
||||
}
|
||||
if spec.title.trim().is_empty() {
|
||||
return Err("统一创作契约标题不能为空".to_string());
|
||||
}
|
||||
|
||||
let workspace_stage = spec.workspace_stage.trim();
|
||||
let generation_stage = spec.generation_stage.trim();
|
||||
let result_stage = spec.result_stage.trim();
|
||||
if workspace_stage.is_empty() || generation_stage.is_empty() || result_stage.is_empty() {
|
||||
return Err("统一创作契约阶段不能为空".to_string());
|
||||
}
|
||||
if workspace_stage == generation_stage
|
||||
|| workspace_stage == result_stage
|
||||
|| generation_stage == result_stage
|
||||
{
|
||||
return Err("统一创作契约阶段不能重复".to_string());
|
||||
}
|
||||
if spec.fields.is_empty() {
|
||||
return Err("统一创作契约 fields 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut field_ids = BTreeSet::new();
|
||||
for field in &spec.fields {
|
||||
let field_id = field.id.trim();
|
||||
if field_id.is_empty() {
|
||||
return Err("统一创作契约字段 id 不能为空".to_string());
|
||||
}
|
||||
if !field_ids.insert(field_id.to_string()) {
|
||||
return Err(format!("统一创作契约字段 id 重复:{field_id}"));
|
||||
}
|
||||
if field.label.trim().is_empty() {
|
||||
return Err(format!("统一创作契约字段 {field_id} 标签不能为空"));
|
||||
}
|
||||
if !UNIFIED_CREATION_FIELD_KINDS.contains(&field.kind.trim()) {
|
||||
return Err(format!(
|
||||
"统一创作契约字段 {field_id} kind 非法:{}",
|
||||
field.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_unified_creation_spec_for_play(
|
||||
play_id: &str,
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<(), String> {
|
||||
if spec.play_id.trim() != play_id.trim() {
|
||||
return Err(format!(
|
||||
"统一创作契约 playId 必须与入口 ID 一致:{}",
|
||||
play_id.trim()
|
||||
));
|
||||
}
|
||||
|
||||
validate_unified_creation_spec_response(spec)
|
||||
}
|
||||
|
||||
pub fn encode_unified_creation_spec_response(
|
||||
spec: &UnifiedCreationSpecResponse,
|
||||
) -> Result<String, String> {
|
||||
validate_unified_creation_spec_response(spec)?;
|
||||
serde_json::to_string(spec).map_err(|error| format!("统一创作契约序列化失败:{error}"))
|
||||
}
|
||||
|
||||
pub fn decode_unified_creation_spec_response(
|
||||
value: &str,
|
||||
) -> Result<UnifiedCreationSpecResponse, String> {
|
||||
let spec = serde_json::from_str::<UnifiedCreationSpecResponse>(value)
|
||||
.map_err(|error| format!("统一创作契约 JSON 非法:{error}"))?;
|
||||
validate_unified_creation_spec_response(&spec)?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
pub fn resolve_unified_creation_spec_response(
|
||||
play_id: &str,
|
||||
value: Option<&str>,
|
||||
) -> Option<UnifiedCreationSpecResponse> {
|
||||
match value {
|
||||
Some(raw) => decode_unified_creation_spec_response(raw).ok(),
|
||||
None => build_phase1_unified_creation_spec(play_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn unified_creation_field(
|
||||
id: &str,
|
||||
kind: &str,
|
||||
label: &str,
|
||||
required: bool,
|
||||
) -> UnifiedCreationFieldResponse {
|
||||
UnifiedCreationFieldResponse {
|
||||
id: id.to_string(),
|
||||
kind: kind.to_string(),
|
||||
label: label.to_string(),
|
||||
required,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn phase1_unified_creation_specs_cover_existing_templates() {
|
||||
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
|
||||
assert_eq!(puzzle.fields[0].id, "pictureDescription");
|
||||
assert_eq!(puzzle.fields[1].kind, "image");
|
||||
|
||||
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
|
||||
assert_eq!(
|
||||
match3d
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|field| field.kind == "select")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "stylePreset")
|
||||
);
|
||||
assert!(
|
||||
jump_hop
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| field.id == "endMoodPrompt")
|
||||
);
|
||||
|
||||
let wooden_fish =
|
||||
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
|
||||
assert!(wooden_fish.fields.iter().any(|field| field.kind == "audio"));
|
||||
|
||||
let visual_novel =
|
||||
build_phase1_unified_creation_spec("visual-novel").expect("visual-novel spec");
|
||||
assert_eq!(visual_novel.workspace_stage, "visual-novel-agent-workspace");
|
||||
|
||||
let bark_battle =
|
||||
build_phase1_unified_creation_spec("bark-battle").expect("bark-battle spec");
|
||||
assert_eq!(bark_battle.generation_stage, "bark-battle-generating");
|
||||
|
||||
let baby_object_match = build_phase1_unified_creation_spec("baby-object-match")
|
||||
.expect("baby-object-match spec");
|
||||
assert_eq!(
|
||||
baby_object_match
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|field| field.kind == "text")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_event_banner_defaults_to_structured_render_mode() {
|
||||
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(
|
||||
r#"{
|
||||
"title": "旧版横幅",
|
||||
"description": "兼容旧字段",
|
||||
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
||||
"prizePoolMudPoints": 1000,
|
||||
"startsAtText": "2026-06-01",
|
||||
"endsAtText": "2026-06-30"
|
||||
}"#,
|
||||
)
|
||||
.expect("legacy banner json should decode");
|
||||
|
||||
assert_eq!(banner.render_mode, "structured");
|
||||
assert!(banner.html_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_config_serializes_event_banners_contract() {
|
||||
let response = CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: "新建作品".to_string(),
|
||||
description: "选择模板".to_string(),
|
||||
idle_badge: "模板".to_string(),
|
||||
busy_badge: "开启中".to_string(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalResponse {
|
||||
title: "选择创作类型".to_string(),
|
||||
description: "先选玩法".to_string(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: "第一条".to_string(),
|
||||
description: "兼容单条".to_string(),
|
||||
cover_image_src: "/creation-type-references/puzzle.webp".to_string(),
|
||||
prize_pool_mud_points: 1000,
|
||||
starts_at_text: "2026-06-01".to_string(),
|
||||
ends_at_text: "2026-06-30".to_string(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners: vec![CreationEntryEventBannerResponse {
|
||||
title: "HTML 条".to_string(),
|
||||
description: "沙箱".to_string(),
|
||||
cover_image_src: "/creation-type-references/match3d.webp".to_string(),
|
||||
prize_pool_mud_points: 800,
|
||||
starts_at_text: "2026-07-01".to_string(),
|
||||
ends_at_text: "2026-07-31".to_string(),
|
||||
render_mode: "html".to_string(),
|
||||
html_code: Some("<section>ok</section>".to_string()),
|
||||
}],
|
||||
creation_types: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(response).expect("response should serialize");
|
||||
|
||||
assert_eq!(value["eventBanners"][0]["renderMode"], "html");
|
||||
assert_eq!(
|
||||
value["eventBanners"][0]["htmlCode"],
|
||||
"<section>ok</section>"
|
||||
);
|
||||
assert!(value.get("event_banner").is_none());
|
||||
assert!(value.get("eventBanner").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,22 @@ pub struct WechatMiniProgramPayParamsResponse {
|
||||
pub pay_sign: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatMiniProgramVirtualPayParamsResponse {
|
||||
pub mode: String,
|
||||
pub sign_data: String,
|
||||
pub pay_sig: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum WechatMiniProgramPaymentParamsResponse {
|
||||
Ordinary(WechatMiniProgramPayParamsResponse),
|
||||
Virtual(WechatMiniProgramVirtualPayParamsResponse),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatH5PaymentResponse {
|
||||
@@ -283,7 +299,7 @@ pub struct CreateProfileRechargeOrderResponse {
|
||||
pub order: ProfileRechargeOrderResponse,
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
#[serde(default)]
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPayParamsResponse>,
|
||||
pub wechat_mini_program_pay_params: Option<WechatMiniProgramPaymentParamsResponse>,
|
||||
#[serde(default)]
|
||||
pub wechat_h5_payment: Option<WechatH5PaymentResponse>,
|
||||
#[serde(default)]
|
||||
@@ -297,6 +313,19 @@ pub struct ConfirmWechatProfileRechargeOrderResponse {
|
||||
pub center: ProfileRechargeCenterResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatProfileRechargeOrderDoneEvent {
|
||||
pub order_id: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WechatProfileRechargeOrderErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileFeedbackEvidenceItemRequest {
|
||||
@@ -1451,6 +1480,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_recharge_order_response_serializes_virtual_wechat_payloads() {
|
||||
let order = ProfileRechargeOrderResponse {
|
||||
order_id: "rcgtest002".to_string(),
|
||||
product_id: "member_month".to_string(),
|
||||
product_title: "月卡".to_string(),
|
||||
kind: "membership".to_string(),
|
||||
amount_cents: 2800,
|
||||
status: "pending".to_string(),
|
||||
payment_channel: "wechat_mp_virtual".to_string(),
|
||||
paid_at: None,
|
||||
provider_transaction_id: None,
|
||||
created_at: "2026-05-15T10:00:00Z".to_string(),
|
||||
points_delta: 0,
|
||||
membership_expires_at: Some("2026-06-15T10:00:00Z".to_string()),
|
||||
};
|
||||
let center = ProfileRechargeCenterResponse {
|
||||
wallet_balance: 0,
|
||||
membership: ProfileMembershipResponse {
|
||||
status: "normal".to_string(),
|
||||
tier: "normal".to_string(),
|
||||
started_at: None,
|
||||
expires_at: None,
|
||||
updated_at: None,
|
||||
},
|
||||
point_products: vec![],
|
||||
membership_products: vec![],
|
||||
benefits: vec![],
|
||||
latest_order: None,
|
||||
has_points_recharged: false,
|
||||
};
|
||||
let payload = serde_json::to_value(CreateProfileRechargeOrderResponse {
|
||||
order,
|
||||
center,
|
||||
wechat_mini_program_pay_params: Some(WechatMiniProgramPaymentParamsResponse::Virtual(
|
||||
WechatMiniProgramVirtualPayParamsResponse {
|
||||
mode: "short_series_goods".to_string(),
|
||||
sign_data:
|
||||
"{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}"
|
||||
.to_string(),
|
||||
pay_sig: "pay-sig".to_string(),
|
||||
signature: "user-sig".to_string(),
|
||||
},
|
||||
)),
|
||||
wechat_h5_payment: None,
|
||||
wechat_native_payment: None,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["mode"],
|
||||
json!("short_series_goods")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["signData"],
|
||||
json!("{\"offerId\":\"offer-1\",\"productId\":\"member_month\",\"goodsPrice\":2800}")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["paySig"],
|
||||
json!("pay-sig")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["wechatMiniProgramPayParams"]["signature"],
|
||||
json!("user-sig")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_feedback_response_uses_camel_case_fields() {
|
||||
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
|
||||
|
||||
Reference in New Issue
Block a user