From d66b17b85553b69e5f7de9debbe5dfe5a1756791 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Tue, 14 Jul 2026 17:01:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=8A=BD=E5=8F=96=E9=85=8D=E8=89=B2css=20(?= =?UTF-8?q?#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 段舒康 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/79 Reviewed-by: 段舒康 Co-authored-by: kvtodev Co-committed-by: kvtodev --- .../shared-memory/decision-log.md | 2 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 2 +- packages/shared/src/theme.css | 534 +++++++++++++++++ packages/shared/src/theme.test.ts | 53 ++ src/index.css | 536 +----------------- src/index.test.ts | 34 +- 6 files changed, 621 insertions(+), 540 deletions(-) create mode 100644 packages/shared/src/theme.css create mode 100644 packages/shared/src/theme.test.ts diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4866f5007..8f5ace7b2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2649,7 +2649,7 @@ # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 - 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。 -- 决策:平台亮色主题的主色回收到暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;后台管理也同步切换到同一暖橙体系。主题变量优先通过 `src/index.css` 的 `--platform-*` token 统一控制,零散组件只做必要的局部替换。 +- 决策:平台亮色主题的主色回收到暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;后台管理也同步切换到同一暖橙体系。主题变量和平台字体栈优先通过 `packages/shared/src/theme.css` 的 `--platform-*` token 统一控制,具体全局字体选择器留在消费端原有层叠位置,零散组件只做必要的局部替换。 - 影响范围:主站平台壳层、常用表单 / 按钮 / 卡片 / 背景、后台管理 UI、业务进度条和小游戏结果条的通用强调色。 - 验证方式:优先检查 `src/index.css` 与 `apps/admin-web/src/styles/admin.css` 是否还存在旧粉色主色;再用编码检查和可执行的本地 typecheck / build 验证。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 8665e27cf..36ff0a5d0 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -108,7 +108,7 @@ server-rs + Axum + SpacetimeDB 12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 -15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 +15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `packages/shared/src/theme.css` 的 `--platform-*` 主题变量和平台字体栈、`apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案;具体全局字体选择器保留在消费端原有层叠位置,避免共享样式导入改变既有视觉。 ## 文案与编码 diff --git a/packages/shared/src/theme.css b/packages/shared/src/theme.css new file mode 100644 index 000000000..08fc0a5b5 --- /dev/null +++ b/packages/shared/src/theme.css @@ -0,0 +1,534 @@ +.platform-theme { + --platform-font-family: 'Inter', 'Noto Sans CJK SC', 'WenQuanYi Zen Hei', + 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif; + color: var(--platform-text-strong); +} + +.platform-theme--light { + color-scheme: light; + --platform-accent: #c7653d; + --platform-body-fill: radial-gradient( + circle at 8% 0%, + rgba(240, 203, 169, 0.24), + transparent 26% + ), + radial-gradient(circle at 92% 8%, rgba(226, 171, 134, 0.2), transparent 22%), + radial-gradient( + circle at 54% 100%, + rgba(204, 117, 76, 0.08), + transparent 30% + ), + linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%); + --platform-panel-shadow: 0 22px 60px rgba(112, 57, 30, 0.1), + 0 8px 22px rgba(255, 255, 255, 0.82); + --platform-panel-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.985), + rgba(253, 248, 243, 0.96) + ); + --platform-panel-fill-soft: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.96), + rgba(250, 243, 236, 0.9) + ); + --platform-hero-fill: linear-gradient( + 135deg, + rgba(244, 226, 211, 0.96), + rgba(238, 208, 183, 0.9) + ); + --platform-hero-glow-a: rgba(255, 255, 255, 0.42); + --platform-hero-glow-b: rgba(228, 149, 91, 0.18); + --platform-hero-overlay-strong: linear-gradient( + 135deg, + rgba(238, 208, 183, 0.66), + rgba(204, 117, 76, 0.18) + ); + --platform-hero-overlay-soft: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.34), + rgba(248, 233, 219, 0.22) + ); + --platform-surface-border: rgba(226, 203, 184, 0.88); + --platform-surface-hover-border: rgba(204, 117, 76, 0.42); + --platform-shell-glow-1: rgba(255, 255, 255, 0.34); + --platform-shell-glow-2: rgba(238, 208, 183, 0.22); + --platform-shell-glow-3: rgba(210, 132, 93, 0.12); + --platform-surface-glow-a: rgba(240, 203, 169, 0.18); + --platform-surface-glow-b: rgba(204, 117, 76, 0.1); + --platform-text-strong: #3d1f10; + --platform-text-base: #6f5848; + --platform-text-soft: #988476; + --platform-text-muted: #a38f80; + --platform-brand-logo-title: #4a220f; + --platform-brand-logo-subtitle: #c7653d; + --platform-brand-logo-shadow: #caa48b; + --platform-line-soft: rgba(226, 203, 184, 0.72); + --platform-subpanel-fill: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.94), + rgba(250, 243, 236, 0.9) + ); + --platform-subpanel-border: rgba(225, 204, 187, 0.82); + --platform-warm-border: rgba(204, 117, 76, 0.28); + --platform-warm-bg: rgba(234, 204, 179, 0.32); + --platform-warm-text: #b6623f; + --platform-cool-border: rgba(199, 117, 76, 0.24); + --platform-cool-bg: rgba(238, 208, 183, 0.26); + --platform-cool-text: #b76038; + --platform-neutral-border: rgba(226, 203, 184, 0.44); + --platform-neutral-bg: rgba(255, 253, 250, 0.7); + --platform-neutral-text: #7b6150; + --platform-button-primary-border: rgba(182, 98, 63, 0.32); + --platform-button-primary-solid: #c7653d; + --platform-button-primary-fill: linear-gradient(135deg, #df7f40, #b95d3a); + --platform-button-primary-text: #fffaf5; + --platform-button-secondary-fill: rgba(255, 253, 250, 0.78); + --platform-button-secondary-text: #4b2412; + --platform-button-ghost-fill: rgba(255, 253, 250, 0.56); + --platform-button-ghost-text: #755a49; + --platform-button-danger-border: rgba(185, 75, 58, 0.22); + --platform-button-danger-fill: rgba(255, 237, 229, 0.94); + --platform-button-danger-text: #a6402f; + --platform-unread-dot-fill: #8b5a3d; + --platform-unread-dot-glow: rgba(139, 90, 61, 0.34); + --platform-success-border: rgba(73, 144, 96, 0.24); + --platform-success-bg: rgba(237, 248, 239, 0.92); + --platform-success-text: #2f7b46; + --platform-icon-fill: rgba(255, 253, 250, 0.68); + --platform-icon-border: rgba(226, 203, 184, 0.5); + --platform-icon-text: #7b5c49; + --platform-nav-fill: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.96), + rgba(250, 243, 236, 0.92) + ); + --platform-nav-active-fill: linear-gradient( + 180deg, + rgba(238, 208, 183, 0.48), + rgba(204, 117, 76, 0.16) + ); + --platform-nav-active-border: rgba(204, 117, 76, 0.34); + --platform-nav-active-shadow: 0 10px 24px rgba(182, 98, 63, 0.12); + --platform-modal-fill: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.97), + rgba(250, 243, 236, 0.95) + ); + --platform-modal-border: rgba(255, 255, 255, 0.58); + --platform-desktop-shell-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.99), + rgba(253, 249, 245, 0.985) + ); + --platform-desktop-shell-border: rgba(230, 213, 199, 0.94); + --platform-desktop-shell-inner-border: rgba(233, 218, 205, 0.92); + --platform-desktop-topbar-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.95), + rgba(253, 249, 245, 0.92) + ); + --platform-desktop-panel-fill: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.95), + rgba(250, 243, 236, 0.91) + ); + --platform-desktop-panel-border: rgba(226, 203, 184, 0.88); + --platform-desktop-hover-shadow: 0 16px 30px rgba(112, 57, 30, 0.12); + --platform-overlay-fill: linear-gradient( + 180deg, + rgba(240, 203, 169, 0.14), + rgba(255, 255, 255, 0.58) + ); + --platform-track-border: rgba(226, 203, 184, 0.48); + --platform-track-fill: rgba(255, 253, 250, 0.9); + --platform-page-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.9), + rgba(250, 243, 236, 0.8) + ); + --platform-page-border: rgba(233, 218, 205, 0.88); + --platform-input-fill: rgba(255, 253, 250, 0.94); + --platform-input-fill-focus: rgba(255, 254, 252, 0.96); + --platform-input-highlight: rgba(255, 255, 255, 0.9); + --platform-input-focus-ring: rgba(204, 117, 76, 0.15); + --platform-nav-item-text: #80695a; + --platform-nav-item-text-active: #3d1f10; + --platform-nav-item-hover-fill: rgba(253, 248, 243, 0.94); + --platform-nav-item-icon-fill: rgba(250, 243, 236, 1); + --platform-nav-item-icon-text: #7b5c49; + --platform-nav-item-icon-active-fill: rgba(255, 254, 252, 0.98); + --platform-nav-item-icon-active-text: #c7653d; + --platform-nav-icon-active-shadow: 0 12px 24px rgba(182, 98, 63, 0.16); + --platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef); + --platform-bottom-nav-border: rgba(224, 181, 139, 0.58); + --platform-bottom-nav-divider: rgba(225, 185, 145, 0.52); + --platform-bottom-nav-text: #5b2b19; + --platform-bottom-nav-muted-text: #7a4b37; + --platform-bottom-nav-active-text: #f15a17; + --platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12); + --platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.28); + --platform-bottom-nav-primary-text: #fffaf2; + --platform-profile-hero-fill: linear-gradient( + 180deg, + rgba(255, 254, 252, 0.96), + rgba(250, 239, 229, 0.9) + ); + --platform-profile-hero-border: rgba(255, 255, 255, 0.56); + --platform-profile-hero-shadow: 0 20px 56px rgba(112, 57, 30, 0.16); + --platform-profile-avatar-fill: linear-gradient( + 135deg, + rgba(223, 127, 64, 0.96), + rgba(181, 91, 56, 0.92) + ); + --platform-profile-avatar-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); + --platform-profile-chip-fill: rgba(255, 253, 250, 0.88); + --platform-profile-chip-hover-fill: rgba(255, 254, 252, 0.96); + --platform-profile-chip-text: #755a49; + --platform-profile-action-fill: linear-gradient(135deg, #df7f40, #b95d3a); + --platform-profile-action-solid: #c7653d; + --platform-profile-action-text: #fffaf5; + --platform-profile-action-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); + --platform-card-overlay-soft: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08), + rgba(250, 243, 236, 0.82) + ); + --platform-card-overlay-strong: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.16), + rgba(248, 239, 231, 0.92) + ); + --platform-card-overlay-deep: radial-gradient( + circle at top left, + rgba(255, 255, 255, 0.2), + transparent 30% + ), + radial-gradient(circle at right, rgba(228, 149, 91, 0.14), transparent 28%), + linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(248, 239, 231, 0.9)); + --platform-recommend-runtime-fill: var(--platform-panel-fill); + --platform-recommend-runtime-border: rgba(226, 203, 184, 0.42); + --platform-recommend-runtime-shadow: 0 18px 44px rgba(112, 57, 30, 0.12), + inset 0 0 0 1px rgba(255, 255, 255, 0.58); + --platform-recommend-runtime-state-fill: radial-gradient( + circle at 50% 18%, + rgba(204, 117, 76, 0.12), + transparent 34% + ), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98), + rgba(250, 243, 236, 0.94) + ); + --platform-recommend-runtime-state-text: var(--platform-text-strong); + --puzzle-runtime-shell-fill: var(--platform-body-fill); + --puzzle-runtime-stage-fill: radial-gradient( + circle at 50% 18%, + rgba(204, 117, 76, 0.13), + transparent 30% + ), + radial-gradient( + circle at 18% 82%, + rgba(240, 203, 169, 0.18), + transparent 28% + ), + linear-gradient(180deg, #fffdf9 0%, #fbf5ed 58%, #f4e5d7 100%); + --puzzle-runtime-grid-line: rgba(112, 57, 30, 0.06); + --puzzle-runtime-text-strong: var(--platform-text-strong); + --puzzle-runtime-text-base: var(--platform-text-base); + --puzzle-runtime-text-soft: var(--platform-text-soft); + --puzzle-runtime-surface-fill: rgba(255, 253, 250, 0.76); + --puzzle-runtime-surface-fill-strong: rgba(255, 253, 250, 0.9); + --puzzle-runtime-surface-border: rgba(226, 203, 184, 0.48); + --puzzle-runtime-board-fill: rgba(255, 253, 250, 0.68); + --puzzle-runtime-board-border: rgba(204, 117, 76, 0.28); + --puzzle-runtime-board-shadow: 0 30px 80px rgba(112, 57, 30, 0.14); + --puzzle-runtime-piece-fill: rgba(255, 253, 250, 0.74); + --puzzle-runtime-piece-border: transparent; + --puzzle-runtime-piece-empty-fill: rgba(238, 208, 183, 0.34); + --puzzle-runtime-piece-empty-text: rgba(111, 88, 72, 0.4); + --puzzle-runtime-piece-selected-fill: linear-gradient( + 135deg, + #df7f40, + #b95d3a + ); + --puzzle-runtime-piece-selected-text: #fffaf5; + --puzzle-runtime-piece-selected-border: transparent; + --puzzle-runtime-next-card-overlay: rgba(74, 34, 15, 0.06); + --puzzle-runtime-control-fill: rgba(255, 253, 250, 0.72); + --puzzle-runtime-control-hover-fill: rgba(204, 117, 76, 0.1); + --puzzle-runtime-primary-fill: var(--platform-button-primary-fill); + --puzzle-runtime-primary-text: var(--platform-button-primary-text); + --puzzle-runtime-primary-shadow: var(--platform-profile-action-shadow); + --puzzle-runtime-accent-text: var(--platform-cool-text); + --puzzle-runtime-cool-text: #6e8d42; + --puzzle-runtime-danger-fill: rgba(255, 237, 229, 0.9); + --puzzle-runtime-danger-text: #a6402f; + --puzzle-runtime-backdrop-fill: rgba(61, 31, 16, 0.34); + --puzzle-runtime-dialog-fill: radial-gradient( + circle at 12% 0%, + rgba(204, 117, 76, 0.18), + transparent 36% + ), + linear-gradient( + 180deg, + rgba(255, 254, 252, 0.98), + rgba(250, 243, 236, 0.95) + ); + --puzzle-runtime-dialog-border: rgba(204, 117, 76, 0.3); + --puzzle-runtime-table-fill: rgba(255, 253, 250, 0.62); + --puzzle-runtime-table-row-fill: rgba(204, 117, 76, 0.12); + --puzzle-runtime-next-card-fill: rgba(255, 253, 250, 0.66); + --puzzle-runtime-next-card-hover-fill: rgba(204, 117, 76, 0.1); +} + +.platform-theme--dark { + color-scheme: dark; + --platform-accent: #5b6cff; + --platform-body-fill: radial-gradient( + circle at top, + rgba(129, 140, 248, 0.2), + transparent 30% + ), + radial-gradient( + circle at top right, + rgba(59, 130, 246, 0.12), + transparent 24% + ), + radial-gradient( + circle at bottom left, + rgba(34, 211, 238, 0.08), + transparent 26% + ), + linear-gradient(180deg, #13151c 0%, #090b11 100%); + --platform-panel-shadow: 0 28px 88px rgba(5, 8, 28, 0.5); + --platform-panel-fill: linear-gradient( + 180deg, + rgba(22, 20, 52, 0.95), + rgba(7, 9, 21, 0.98) + ); + --platform-panel-fill-soft: linear-gradient( + 180deg, + rgba(122, 92, 255, 0.12), + rgba(255, 255, 255, 0.03) + ); + --platform-hero-fill: radial-gradient( + circle at top left, + rgba(129, 140, 248, 0.24), + transparent 34% + ), + radial-gradient(circle at right, rgba(34, 211, 238, 0.12), transparent 32%), + linear-gradient(180deg, rgba(17, 22, 66, 0.95), rgba(8, 10, 24, 0.98)); + --platform-hero-glow-a: rgba(129, 140, 248, 0.18); + --platform-hero-glow-b: rgba(34, 211, 238, 0.1); + --platform-surface-border: rgba(160, 169, 255, 0.12); + --platform-surface-hover-border: rgba(255, 255, 255, 0.16); + --platform-shell-glow-1: rgba(129, 140, 248, 0.2); + --platform-shell-glow-2: rgba(59, 130, 246, 0.12); + --platform-shell-glow-3: rgba(34, 211, 238, 0.08); + --platform-surface-glow-a: rgba(129, 140, 248, 0.18); + --platform-surface-glow-b: rgba(34, 211, 238, 0.1); + --platform-text-strong: #ffffff; + --platform-text-base: rgb(228 228 231); + --platform-text-soft: rgb(161 161 170); + --platform-text-muted: rgb(161 161 170); + --platform-brand-logo-title: #fff7dc; + --platform-brand-logo-subtitle: #9fe7ff; + --platform-brand-logo-shadow: #040814; + --platform-line-soft: rgba(255, 255, 255, 0.1); + --platform-subpanel-fill: rgba(255, 255, 255, 0.05); + --platform-subpanel-border: rgba(255, 255, 255, 0.1); + --platform-warm-border: rgba(167, 139, 250, 0.24); + --platform-warm-bg: rgba(109, 40, 217, 0.18); + --platform-warm-text: rgb(237 233 254); + --platform-cool-border: rgba(103, 232, 249, 0.22); + --platform-cool-bg: rgba(8, 145, 178, 0.14); + --platform-cool-text: rgb(236 254 255); + --platform-neutral-border: rgba(255, 255, 255, 0.08); + --platform-neutral-bg: rgba(255, 255, 255, 0.05); + --platform-neutral-text: rgb(228 228 231); + --platform-button-primary-border: rgba(129, 140, 248, 0.3); + --platform-button-primary-solid: #5b6cff; + --platform-button-primary-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); + --platform-button-primary-text: rgb(238 248 255); + --platform-button-secondary-fill: rgba(255, 255, 255, 0.05); + --platform-button-secondary-text: rgb(244 244 245); + --platform-button-ghost-fill: rgba(255, 255, 255, 0.03); + --platform-button-ghost-text: rgb(212 212 216); + --platform-button-danger-border: rgba(251, 113, 133, 0.2); + --platform-button-danger-fill: rgba(244, 63, 94, 0.1); + --platform-button-danger-text: rgb(255 228 230); + --platform-unread-dot-fill: #d6a27c; + --platform-unread-dot-glow: rgba(214, 162, 124, 0.24); + --platform-success-border: rgba(52, 211, 153, 0.24); + --platform-success-bg: rgba(16, 185, 129, 0.1); + --platform-success-text: rgb(220 252 231); + --platform-icon-fill: rgba(255, 255, 255, 0.05); + --platform-icon-border: rgba(255, 255, 255, 0.1); + --platform-icon-text: rgb(212 212 216); + --platform-nav-fill: linear-gradient( + 180deg, + rgba(109, 40, 217, 0.12), + rgba(255, 255, 255, 0.03) + ); + --platform-nav-active-fill: linear-gradient( + 180deg, + rgba(91, 108, 255, 0.2), + rgba(61, 217, 255, 0.08) + ); + --platform-nav-active-border: rgba(160, 169, 255, 0.24); + --platform-nav-active-shadow: 0 12px 28px rgba(8, 14, 42, 0.4); + --platform-modal-fill: linear-gradient( + 180deg, + rgba(16, 18, 46, 0.98), + rgba(7, 8, 19, 0.98) + ); + --platform-modal-border: rgba(160, 169, 255, 0.12); + --platform-desktop-shell-fill: linear-gradient( + 180deg, + rgba(8, 8, 30, 0.98), + rgba(5, 6, 18, 0.99) + ); + --platform-desktop-shell-border: rgba(160, 169, 255, 0.14); + --platform-desktop-shell-inner-border: rgba(255, 255, 255, 0.03); + --platform-desktop-topbar-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.03) + ); + --platform-desktop-panel-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.05), + rgba(255, 255, 255, 0.02) + ); + --platform-desktop-panel-border: rgba(160, 169, 255, 0.12); + --platform-desktop-hover-shadow: 0 16px 28px rgba(8, 11, 38, 0.2); + --platform-overlay-fill: rgba(5, 8, 28, 0.72); + --platform-track-border: rgba(255, 255, 255, 0.12); + --platform-track-fill: rgba(255, 255, 255, 0.08); + --platform-page-fill: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.04), + rgba(255, 255, 255, 0.02) + ); + --platform-page-border: rgba(255, 255, 255, 0.06); + --platform-input-fill: rgba(255, 255, 255, 0.05); + --platform-input-fill-focus: rgba(255, 255, 255, 0.08); + --platform-input-highlight: rgba(255, 255, 255, 0.06); + --platform-input-focus-ring: rgba(91, 108, 255, 0.22); + --platform-nav-item-text: rgb(161 161 170); + --platform-nav-item-text-active: rgb(238 248 255); + --platform-nav-item-hover-fill: rgba(91, 108, 255, 0.08); + --platform-nav-item-icon-fill: rgba(255, 255, 255, 0.06); + --platform-nav-item-icon-text: rgb(161 161 170); + --platform-nav-item-icon-active-fill: linear-gradient( + 180deg, + rgba(91, 108, 255, 0.24), + rgba(61, 217, 255, 0.12) + ); + --platform-nav-item-icon-active-text: rgb(238 248 255); + --platform-nav-icon-active-shadow: 0 12px 24px rgba(8, 14, 42, 0.42); + --platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef); + --platform-bottom-nav-border: rgba(224, 181, 139, 0.64); + --platform-bottom-nav-divider: rgba(225, 185, 145, 0.56); + --platform-bottom-nav-text: #5b2b19; + --platform-bottom-nav-muted-text: #7a4b37; + --platform-bottom-nav-active-text: #f15a17; + --platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12); + --platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.3); + --platform-bottom-nav-primary-text: #fffaf2; + --platform-profile-hero-fill: linear-gradient( + 180deg, + rgba(20, 24, 58, 0.96), + rgba(8, 10, 24, 0.98) + ); + --platform-profile-hero-border: rgba(160, 169, 255, 0.14); + --platform-profile-hero-shadow: 0 24px 70px rgba(5, 8, 28, 0.42); + --platform-profile-avatar-fill: linear-gradient( + 135deg, + rgba(91, 108, 255, 0.94), + rgba(61, 217, 255, 0.78) + ); + --platform-profile-avatar-shadow: 0 14px 32px rgba(61, 217, 255, 0.16); + --platform-profile-chip-fill: rgba(255, 255, 255, 0.08); + --platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.14); + --platform-profile-chip-text: rgb(228 228 231); + --platform-profile-action-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); + --platform-profile-action-solid: #5b6cff; + --platform-profile-action-text: rgb(238 248 255); + --platform-profile-action-shadow: 0 14px 32px rgba(91, 108, 255, 0.22); + --platform-card-overlay-soft: linear-gradient( + 180deg, + rgba(8, 10, 14, 0.06), + rgba(8, 10, 14, 0.74) + ); + --platform-card-overlay-strong: linear-gradient( + 180deg, + rgba(8, 10, 14, 0.14), + rgba(8, 10, 14, 0.9) + ); + --platform-card-overlay-deep: radial-gradient( + circle at top left, + rgba(255, 255, 255, 0.14), + transparent 30% + ), + radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%), + linear-gradient(180deg, rgba(8, 10, 14, 0.22), rgba(8, 10, 14, 0.9)); + --platform-recommend-runtime-fill: #030303; + --platform-recommend-runtime-border: rgba(255, 255, 255, 0.08); + --platform-recommend-runtime-shadow: inset 0 0 0 1px + rgba(255, 255, 255, 0.025), + 0 18px 44px rgba(0, 0, 0, 0.18); + --platform-recommend-runtime-state-fill: #030303; + --platform-recommend-runtime-state-text: rgba(255, 255, 255, 0.92); + --puzzle-runtime-shell-fill: #020617; + --puzzle-runtime-stage-fill: radial-gradient( + circle at 50% 20%, + rgba(251, 191, 36, 0.18), + transparent 28% + ), + radial-gradient( + circle at 20% 80%, + rgba(249, 115, 22, 0.16), + transparent 26% + ), + linear-gradient(180deg, #2d160e, #020617); + --puzzle-runtime-grid-line: rgba(255, 255, 255, 0.04); + --puzzle-runtime-text-strong: #ffffff; + --puzzle-runtime-text-base: rgba(255, 255, 255, 0.82); + --puzzle-runtime-text-soft: rgba(255, 255, 255, 0.58); + --puzzle-runtime-surface-fill: rgba(0, 0, 0, 0.34); + --puzzle-runtime-surface-fill-strong: rgba(0, 0, 0, 0.5); + --puzzle-runtime-surface-border: rgba(255, 255, 255, 0.12); + --puzzle-runtime-board-fill: rgba(255, 255, 255, 0.08); + --puzzle-runtime-board-border: rgba(255, 255, 255, 0.16); + --puzzle-runtime-board-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); + --puzzle-runtime-piece-fill: rgba(255, 255, 255, 0.12); + --puzzle-runtime-piece-border: transparent; + --puzzle-runtime-piece-empty-fill: rgba(0, 0, 0, 0.18); + --puzzle-runtime-piece-empty-text: rgba(255, 255, 255, 0.2); + --puzzle-runtime-piece-selected-fill: rgba(251, 191, 36, 0.84); + --puzzle-runtime-piece-selected-text: #020617; + --puzzle-runtime-piece-selected-border: transparent; + --puzzle-runtime-next-card-overlay: rgba(0, 0, 0, 0.1); + --puzzle-runtime-control-fill: rgba(0, 0, 0, 0.36); + --puzzle-runtime-control-hover-fill: rgba(255, 255, 255, 0.1); + --puzzle-runtime-primary-fill: #fde68a; + --puzzle-runtime-primary-text: #020617; + --puzzle-runtime-primary-shadow: 0 14px 36px rgba(251, 191, 36, 0.26); + --puzzle-runtime-accent-text: #fde68a; + --puzzle-runtime-cool-text: #cffafe; + --puzzle-runtime-danger-fill: rgba(239, 68, 68, 0.2); + --puzzle-runtime-danger-text: #fee2e2; + --puzzle-runtime-backdrop-fill: rgba(2, 6, 23, 0.68); + --puzzle-runtime-dialog-fill: radial-gradient( + circle at 14% 0%, + rgba(251, 191, 36, 0.16), + transparent 36% + ), + linear-gradient(180deg, rgba(29, 17, 12, 0.96), rgba(2, 6, 23, 0.96)); + --puzzle-runtime-dialog-border: rgba(251, 191, 36, 0.22); + --puzzle-runtime-table-fill: rgba(255, 255, 255, 0.06); + --puzzle-runtime-table-row-fill: rgba(251, 191, 36, 0.14); + --puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.06); + --puzzle-runtime-next-card-hover-fill: rgba(251, 191, 36, 0.1); +} diff --git a/packages/shared/src/theme.test.ts b/packages/shared/src/theme.test.ts new file mode 100644 index 000000000..0fe6c9c9a --- /dev/null +++ b/packages/shared/src/theme.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; + +const themePath = path.resolve( + process.cwd(), + 'packages/shared/src/theme.css', +); + +function getCssBlock(source: string, selector: string) { + const selectorIndex = source.indexOf(selector); + expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0); + + const openBraceIndex = source.indexOf('{', selectorIndex); + let depth = 0; + for (let index = openBraceIndex; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(openBraceIndex + 1, index); + } + } + } + + throw new Error(`${selector} block is not closed`); +} + +describe('shared platform theme', () => { + it('exposes the shared platform font stack without fixing its cascade position', () => { + const css = fs.readFileSync(themePath, 'utf8'); + const platformTheme = getCssBlock(css, '.platform-theme'); + + expect(platformTheme).toContain("--platform-font-family: 'Inter'"); + expect(css).not.toContain('.platform-theme,\n.platform-theme *'); + }); + + it('keeps the light and dark semantic color contracts', () => { + const css = fs.readFileSync(themePath, 'utf8'); + const light = getCssBlock(css, '.platform-theme--light'); + const dark = getCssBlock(css, '.platform-theme--dark'); + + expect(light).toContain('--platform-accent: #c7653d;'); + expect(light).toContain('linear-gradient(135deg, #df7f40, #b95d3a)'); + expect(light).toContain('--platform-text-strong: #3d1f10;'); + expect(dark).toContain('--platform-accent: #5b6cff;'); + expect(dark).toContain('linear-gradient(135deg, #5b6cff, #3dd9ff)'); + expect(dark).toContain('--platform-text-strong: #ffffff;'); + }); +}); diff --git a/src/index.css b/src/index.css index d8c13ac58..463ba71b3 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,6 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap'); @import 'tailwindcss'; +@import '@/packages/shared/src/theme.css'; @source not "../dist"; @source not "../dist_check"; @source not "../dist_check_final"; @@ -959,542 +960,9 @@ html[data-mobile-keyboard-open='true'] #root { .platform-theme, .platform-theme * { - font-family: 'Inter', 'Noto Sans CJK SC', 'WenQuanYi Zen Hei', 'PingFang SC', - 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif !important; + font-family: var(--platform-font-family) !important; } -.platform-theme { - color: var(--platform-text-strong); -} - -.platform-theme--light { - color-scheme: light; - --platform-accent: #c7653d; - --platform-body-fill: radial-gradient( - circle at 8% 0%, - rgba(240, 203, 169, 0.24), - transparent 26% - ), - radial-gradient(circle at 92% 8%, rgba(226, 171, 134, 0.2), transparent 22%), - radial-gradient( - circle at 54% 100%, - rgba(204, 117, 76, 0.08), - transparent 30% - ), - linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%); - --platform-panel-shadow: 0 22px 60px rgba(112, 57, 30, 0.1), - 0 8px 22px rgba(255, 255, 255, 0.82); - --platform-panel-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.985), - rgba(253, 248, 243, 0.96) - ); - --platform-panel-fill-soft: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.96), - rgba(250, 243, 236, 0.9) - ); - --platform-hero-fill: linear-gradient( - 135deg, - rgba(244, 226, 211, 0.96), - rgba(238, 208, 183, 0.9) - ); - --platform-hero-glow-a: rgba(255, 255, 255, 0.42); - --platform-hero-glow-b: rgba(228, 149, 91, 0.18); - --platform-hero-overlay-strong: linear-gradient( - 135deg, - rgba(238, 208, 183, 0.66), - rgba(204, 117, 76, 0.18) - ); - --platform-hero-overlay-soft: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.34), - rgba(248, 233, 219, 0.22) - ); - --platform-surface-border: rgba(226, 203, 184, 0.88); - --platform-surface-hover-border: rgba(204, 117, 76, 0.42); - --platform-shell-glow-1: rgba(255, 255, 255, 0.34); - --platform-shell-glow-2: rgba(238, 208, 183, 0.22); - --platform-shell-glow-3: rgba(210, 132, 93, 0.12); - --platform-surface-glow-a: rgba(240, 203, 169, 0.18); - --platform-surface-glow-b: rgba(204, 117, 76, 0.1); - --platform-text-strong: #3d1f10; - --platform-text-base: #6f5848; - --platform-text-soft: #988476; - --platform-text-muted: #a38f80; - --platform-brand-logo-title: #4a220f; - --platform-brand-logo-subtitle: #c7653d; - --platform-brand-logo-shadow: #caa48b; - --platform-line-soft: rgba(226, 203, 184, 0.72); - --platform-subpanel-fill: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.94), - rgba(250, 243, 236, 0.9) - ); - --platform-subpanel-border: rgba(225, 204, 187, 0.82); - --platform-warm-border: rgba(204, 117, 76, 0.28); - --platform-warm-bg: rgba(234, 204, 179, 0.32); - --platform-warm-text: #b6623f; - --platform-cool-border: rgba(199, 117, 76, 0.24); - --platform-cool-bg: rgba(238, 208, 183, 0.26); - --platform-cool-text: #b76038; - --platform-neutral-border: rgba(226, 203, 184, 0.44); - --platform-neutral-bg: rgba(255, 253, 250, 0.7); - --platform-neutral-text: #7b6150; - --platform-button-primary-border: rgba(182, 98, 63, 0.32); - --platform-button-primary-solid: #c7653d; - --platform-button-primary-fill: linear-gradient(135deg, #df7f40, #b95d3a); - --platform-button-primary-text: #fffaf5; - --platform-button-secondary-fill: rgba(255, 253, 250, 0.78); - --platform-button-secondary-text: #4b2412; - --platform-button-ghost-fill: rgba(255, 253, 250, 0.56); - --platform-button-ghost-text: #755a49; - --platform-button-danger-border: rgba(185, 75, 58, 0.22); - --platform-button-danger-fill: rgba(255, 237, 229, 0.94); - --platform-button-danger-text: #a6402f; - --platform-unread-dot-fill: #8b5a3d; - --platform-unread-dot-glow: rgba(139, 90, 61, 0.34); - --platform-success-border: rgba(73, 144, 96, 0.24); - --platform-success-bg: rgba(237, 248, 239, 0.92); - --platform-success-text: #2f7b46; - --platform-icon-fill: rgba(255, 253, 250, 0.68); - --platform-icon-border: rgba(226, 203, 184, 0.5); - --platform-icon-text: #7b5c49; - --platform-nav-fill: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.96), - rgba(250, 243, 236, 0.92) - ); - --platform-nav-active-fill: linear-gradient( - 180deg, - rgba(238, 208, 183, 0.48), - rgba(204, 117, 76, 0.16) - ); - --platform-nav-active-border: rgba(204, 117, 76, 0.34); - --platform-nav-active-shadow: 0 10px 24px rgba(182, 98, 63, 0.12); - --platform-modal-fill: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.97), - rgba(250, 243, 236, 0.95) - ); - --platform-modal-border: rgba(255, 255, 255, 0.58); - --platform-desktop-shell-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.99), - rgba(253, 249, 245, 0.985) - ); - --platform-desktop-shell-border: rgba(230, 213, 199, 0.94); - --platform-desktop-shell-inner-border: rgba(233, 218, 205, 0.92); - --platform-desktop-topbar-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.95), - rgba(253, 249, 245, 0.92) - ); - --platform-desktop-panel-fill: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.95), - rgba(250, 243, 236, 0.91) - ); - --platform-desktop-panel-border: rgba(226, 203, 184, 0.88); - --platform-desktop-hover-shadow: 0 16px 30px rgba(112, 57, 30, 0.12); - --platform-overlay-fill: linear-gradient( - 180deg, - rgba(240, 203, 169, 0.14), - rgba(255, 255, 255, 0.58) - ); - --platform-track-border: rgba(226, 203, 184, 0.48); - --platform-track-fill: rgba(255, 253, 250, 0.9); - --platform-page-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.9), - rgba(250, 243, 236, 0.8) - ); - --platform-page-border: rgba(233, 218, 205, 0.88); - --platform-input-fill: rgba(255, 253, 250, 0.94); - --platform-input-fill-focus: rgba(255, 254, 252, 0.96); - --platform-input-highlight: rgba(255, 255, 255, 0.9); - --platform-input-focus-ring: rgba(204, 117, 76, 0.15); - --platform-nav-item-text: #80695a; - --platform-nav-item-text-active: #3d1f10; - --platform-nav-item-hover-fill: rgba(253, 248, 243, 0.94); - --platform-nav-item-icon-fill: rgba(250, 243, 236, 1); - --platform-nav-item-icon-text: #7b5c49; - --platform-nav-item-icon-active-fill: rgba(255, 254, 252, 0.98); - --platform-nav-item-icon-active-text: #c7653d; - --platform-nav-icon-active-shadow: 0 12px 24px rgba(182, 98, 63, 0.16); - --platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef); - --platform-bottom-nav-border: rgba(224, 181, 139, 0.58); - --platform-bottom-nav-divider: rgba(225, 185, 145, 0.52); - --platform-bottom-nav-text: #5b2b19; - --platform-bottom-nav-muted-text: #7a4b37; - --platform-bottom-nav-active-text: #f15a17; - --platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12); - --platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.28); - --platform-bottom-nav-primary-text: #fffaf2; - --platform-profile-hero-fill: linear-gradient( - 180deg, - rgba(255, 254, 252, 0.96), - rgba(250, 239, 229, 0.9) - ); - --platform-profile-hero-border: rgba(255, 255, 255, 0.56); - --platform-profile-hero-shadow: 0 20px 56px rgba(112, 57, 30, 0.16); - --platform-profile-avatar-fill: linear-gradient( - 135deg, - rgba(223, 127, 64, 0.96), - rgba(181, 91, 56, 0.92) - ); - --platform-profile-avatar-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); - --platform-profile-chip-fill: rgba(255, 253, 250, 0.88); - --platform-profile-chip-hover-fill: rgba(255, 254, 252, 0.96); - --platform-profile-chip-text: #755a49; - --platform-profile-action-fill: linear-gradient(135deg, #df7f40, #b95d3a); - --platform-profile-action-solid: #c7653d; - --platform-profile-action-text: #fffaf5; - --platform-profile-action-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); - --platform-card-overlay-soft: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.08), - rgba(250, 243, 236, 0.82) - ); - --platform-card-overlay-strong: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.16), - rgba(248, 239, 231, 0.92) - ); - --platform-card-overlay-deep: radial-gradient( - circle at top left, - rgba(255, 255, 255, 0.2), - transparent 30% - ), - radial-gradient(circle at right, rgba(228, 149, 91, 0.14), transparent 28%), - linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(248, 239, 231, 0.9)); - --platform-recommend-runtime-fill: var(--platform-panel-fill); - --platform-recommend-runtime-border: rgba(226, 203, 184, 0.42); - --platform-recommend-runtime-shadow: 0 18px 44px rgba(112, 57, 30, 0.12), - inset 0 0 0 1px rgba(255, 255, 255, 0.58); - --platform-recommend-runtime-state-fill: radial-gradient( - circle at 50% 18%, - rgba(204, 117, 76, 0.12), - transparent 34% - ), - linear-gradient( - 180deg, - rgba(255, 255, 255, 0.98), - rgba(250, 243, 236, 0.94) - ); - --platform-recommend-runtime-state-text: var(--platform-text-strong); - --puzzle-runtime-shell-fill: var(--platform-body-fill); - --puzzle-runtime-stage-fill: radial-gradient( - circle at 50% 18%, - rgba(204, 117, 76, 0.13), - transparent 30% - ), - radial-gradient( - circle at 18% 82%, - rgba(240, 203, 169, 0.18), - transparent 28% - ), - linear-gradient(180deg, #fffdf9 0%, #fbf5ed 58%, #f4e5d7 100%); - --puzzle-runtime-grid-line: rgba(112, 57, 30, 0.06); - --puzzle-runtime-text-strong: var(--platform-text-strong); - --puzzle-runtime-text-base: var(--platform-text-base); - --puzzle-runtime-text-soft: var(--platform-text-soft); - --puzzle-runtime-surface-fill: rgba(255, 253, 250, 0.76); - --puzzle-runtime-surface-fill-strong: rgba(255, 253, 250, 0.9); - --puzzle-runtime-surface-border: rgba(226, 203, 184, 0.48); - --puzzle-runtime-board-fill: rgba(255, 253, 250, 0.68); - --puzzle-runtime-board-border: rgba(204, 117, 76, 0.28); - --puzzle-runtime-board-shadow: 0 30px 80px rgba(112, 57, 30, 0.14); - --puzzle-runtime-piece-fill: rgba(255, 253, 250, 0.74); - --puzzle-runtime-piece-border: transparent; - --puzzle-runtime-piece-empty-fill: rgba(238, 208, 183, 0.34); - --puzzle-runtime-piece-empty-text: rgba(111, 88, 72, 0.4); - --puzzle-runtime-piece-selected-fill: linear-gradient( - 135deg, - #df7f40, - #b95d3a - ); - --puzzle-runtime-piece-selected-text: #fffaf5; - --puzzle-runtime-piece-selected-border: transparent; - --puzzle-runtime-next-card-overlay: rgba(74, 34, 15, 0.06); - --puzzle-runtime-control-fill: rgba(255, 253, 250, 0.72); - --puzzle-runtime-control-hover-fill: rgba(204, 117, 76, 0.1); - --puzzle-runtime-primary-fill: var(--platform-button-primary-fill); - --puzzle-runtime-primary-text: var(--platform-button-primary-text); - --puzzle-runtime-primary-shadow: var(--platform-profile-action-shadow); - --puzzle-runtime-accent-text: var(--platform-cool-text); - --puzzle-runtime-cool-text: #6e8d42; - --puzzle-runtime-danger-fill: rgba(255, 237, 229, 0.9); - --puzzle-runtime-danger-text: #a6402f; - --puzzle-runtime-backdrop-fill: rgba(61, 31, 16, 0.34); - --puzzle-runtime-dialog-fill: radial-gradient( - circle at 12% 0%, - rgba(204, 117, 76, 0.18), - transparent 36% - ), - linear-gradient( - 180deg, - rgba(255, 254, 252, 0.98), - rgba(250, 243, 236, 0.95) - ); - --puzzle-runtime-dialog-border: rgba(204, 117, 76, 0.3); - --puzzle-runtime-table-fill: rgba(255, 253, 250, 0.62); - --puzzle-runtime-table-row-fill: rgba(204, 117, 76, 0.12); - --puzzle-runtime-next-card-fill: rgba(255, 253, 250, 0.66); - --puzzle-runtime-next-card-hover-fill: rgba(204, 117, 76, 0.1); -} - -.platform-theme--dark { - color-scheme: dark; - --platform-accent: #5b6cff; - --platform-body-fill: radial-gradient( - circle at top, - rgba(129, 140, 248, 0.2), - transparent 30% - ), - radial-gradient( - circle at top right, - rgba(59, 130, 246, 0.12), - transparent 24% - ), - radial-gradient( - circle at bottom left, - rgba(34, 211, 238, 0.08), - transparent 26% - ), - linear-gradient(180deg, #13151c 0%, #090b11 100%); - --platform-panel-shadow: 0 28px 88px rgba(5, 8, 28, 0.5); - --platform-panel-fill: linear-gradient( - 180deg, - rgba(22, 20, 52, 0.95), - rgba(7, 9, 21, 0.98) - ); - --platform-panel-fill-soft: linear-gradient( - 180deg, - rgba(122, 92, 255, 0.12), - rgba(255, 255, 255, 0.03) - ); - --platform-hero-fill: radial-gradient( - circle at top left, - rgba(129, 140, 248, 0.24), - transparent 34% - ), - radial-gradient(circle at right, rgba(34, 211, 238, 0.12), transparent 32%), - linear-gradient(180deg, rgba(17, 22, 66, 0.95), rgba(8, 10, 24, 0.98)); - --platform-hero-glow-a: rgba(129, 140, 248, 0.18); - --platform-hero-glow-b: rgba(34, 211, 238, 0.1); - --platform-surface-border: rgba(160, 169, 255, 0.12); - --platform-surface-hover-border: rgba(255, 255, 255, 0.16); - --platform-shell-glow-1: rgba(129, 140, 248, 0.2); - --platform-shell-glow-2: rgba(59, 130, 246, 0.12); - --platform-shell-glow-3: rgba(34, 211, 238, 0.08); - --platform-surface-glow-a: rgba(129, 140, 248, 0.18); - --platform-surface-glow-b: rgba(34, 211, 238, 0.1); - --platform-text-strong: #ffffff; - --platform-text-base: rgb(228 228 231); - --platform-text-soft: rgb(161 161 170); - --platform-text-muted: rgb(161 161 170); - --platform-brand-logo-title: #fff7dc; - --platform-brand-logo-subtitle: #9fe7ff; - --platform-brand-logo-shadow: #040814; - --platform-line-soft: rgba(255, 255, 255, 0.1); - --platform-subpanel-fill: rgba(255, 255, 255, 0.05); - --platform-subpanel-border: rgba(255, 255, 255, 0.1); - --platform-warm-border: rgba(167, 139, 250, 0.24); - --platform-warm-bg: rgba(109, 40, 217, 0.18); - --platform-warm-text: rgb(237 233 254); - --platform-cool-border: rgba(103, 232, 249, 0.22); - --platform-cool-bg: rgba(8, 145, 178, 0.14); - --platform-cool-text: rgb(236 254 255); - --platform-neutral-border: rgba(255, 255, 255, 0.08); - --platform-neutral-bg: rgba(255, 255, 255, 0.05); - --platform-neutral-text: rgb(228 228 231); - --platform-button-primary-border: rgba(129, 140, 248, 0.3); - --platform-button-primary-solid: #5b6cff; - --platform-button-primary-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); - --platform-button-primary-text: rgb(238 248 255); - --platform-button-secondary-fill: rgba(255, 255, 255, 0.05); - --platform-button-secondary-text: rgb(244 244 245); - --platform-button-ghost-fill: rgba(255, 255, 255, 0.03); - --platform-button-ghost-text: rgb(212 212 216); - --platform-button-danger-border: rgba(251, 113, 133, 0.2); - --platform-button-danger-fill: rgba(244, 63, 94, 0.1); - --platform-button-danger-text: rgb(255 228 230); - --platform-unread-dot-fill: #d6a27c; - --platform-unread-dot-glow: rgba(214, 162, 124, 0.24); - --platform-success-border: rgba(52, 211, 153, 0.24); - --platform-success-bg: rgba(16, 185, 129, 0.1); - --platform-success-text: rgb(220 252 231); - --platform-icon-fill: rgba(255, 255, 255, 0.05); - --platform-icon-border: rgba(255, 255, 255, 0.1); - --platform-icon-text: rgb(212 212 216); - --platform-nav-fill: linear-gradient( - 180deg, - rgba(109, 40, 217, 0.12), - rgba(255, 255, 255, 0.03) - ); - --platform-nav-active-fill: linear-gradient( - 180deg, - rgba(91, 108, 255, 0.2), - rgba(61, 217, 255, 0.08) - ); - --platform-nav-active-border: rgba(160, 169, 255, 0.24); - --platform-nav-active-shadow: 0 12px 28px rgba(8, 14, 42, 0.4); - --platform-modal-fill: linear-gradient( - 180deg, - rgba(16, 18, 46, 0.98), - rgba(7, 8, 19, 0.98) - ); - --platform-modal-border: rgba(160, 169, 255, 0.12); - --platform-desktop-shell-fill: linear-gradient( - 180deg, - rgba(8, 8, 30, 0.98), - rgba(5, 6, 18, 0.99) - ); - --platform-desktop-shell-border: rgba(160, 169, 255, 0.14); - --platform-desktop-shell-inner-border: rgba(255, 255, 255, 0.03); - --platform-desktop-topbar-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.05), - rgba(255, 255, 255, 0.03) - ); - --platform-desktop-panel-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.05), - rgba(255, 255, 255, 0.02) - ); - --platform-desktop-panel-border: rgba(160, 169, 255, 0.12); - --platform-desktop-hover-shadow: 0 16px 28px rgba(8, 11, 38, 0.2); - --platform-overlay-fill: rgba(5, 8, 28, 0.72); - --platform-track-border: rgba(255, 255, 255, 0.12); - --platform-track-fill: rgba(255, 255, 255, 0.08); - --platform-page-fill: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.04), - rgba(255, 255, 255, 0.02) - ); - --platform-page-border: rgba(255, 255, 255, 0.06); - --platform-input-fill: rgba(255, 255, 255, 0.05); - --platform-input-fill-focus: rgba(255, 255, 255, 0.08); - --platform-input-highlight: rgba(255, 255, 255, 0.06); - --platform-input-focus-ring: rgba(91, 108, 255, 0.22); - --platform-nav-item-text: rgb(161 161 170); - --platform-nav-item-text-active: rgb(238 248 255); - --platform-nav-item-hover-fill: rgba(91, 108, 255, 0.08); - --platform-nav-item-icon-fill: rgba(255, 255, 255, 0.06); - --platform-nav-item-icon-text: rgb(161 161 170); - --platform-nav-item-icon-active-fill: linear-gradient( - 180deg, - rgba(91, 108, 255, 0.24), - rgba(61, 217, 255, 0.12) - ); - --platform-nav-item-icon-active-text: rgb(238 248 255); - --platform-nav-icon-active-shadow: 0 12px 24px rgba(8, 14, 42, 0.42); - --platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef); - --platform-bottom-nav-border: rgba(224, 181, 139, 0.64); - --platform-bottom-nav-divider: rgba(225, 185, 145, 0.56); - --platform-bottom-nav-text: #5b2b19; - --platform-bottom-nav-muted-text: #7a4b37; - --platform-bottom-nav-active-text: #f15a17; - --platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12); - --platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.3); - --platform-bottom-nav-primary-text: #fffaf2; - --platform-profile-hero-fill: linear-gradient( - 180deg, - rgba(20, 24, 58, 0.96), - rgba(8, 10, 24, 0.98) - ); - --platform-profile-hero-border: rgba(160, 169, 255, 0.14); - --platform-profile-hero-shadow: 0 24px 70px rgba(5, 8, 28, 0.42); - --platform-profile-avatar-fill: linear-gradient( - 135deg, - rgba(91, 108, 255, 0.94), - rgba(61, 217, 255, 0.78) - ); - --platform-profile-avatar-shadow: 0 14px 32px rgba(61, 217, 255, 0.16); - --platform-profile-chip-fill: rgba(255, 255, 255, 0.08); - --platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.14); - --platform-profile-chip-text: rgb(228 228 231); - --platform-profile-action-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); - --platform-profile-action-solid: #5b6cff; - --platform-profile-action-text: rgb(238 248 255); - --platform-profile-action-shadow: 0 14px 32px rgba(91, 108, 255, 0.22); - --platform-card-overlay-soft: linear-gradient( - 180deg, - rgba(8, 10, 14, 0.06), - rgba(8, 10, 14, 0.74) - ); - --platform-card-overlay-strong: linear-gradient( - 180deg, - rgba(8, 10, 14, 0.14), - rgba(8, 10, 14, 0.9) - ); - --platform-card-overlay-deep: radial-gradient( - circle at top left, - rgba(255, 255, 255, 0.14), - transparent 30% - ), - radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%), - linear-gradient(180deg, rgba(8, 10, 14, 0.22), rgba(8, 10, 14, 0.9)); - --platform-recommend-runtime-fill: #030303; - --platform-recommend-runtime-border: rgba(255, 255, 255, 0.08); - --platform-recommend-runtime-shadow: inset 0 0 0 1px - rgba(255, 255, 255, 0.025), - 0 18px 44px rgba(0, 0, 0, 0.18); - --platform-recommend-runtime-state-fill: #030303; - --platform-recommend-runtime-state-text: rgba(255, 255, 255, 0.92); - --puzzle-runtime-shell-fill: #020617; - --puzzle-runtime-stage-fill: radial-gradient( - circle at 50% 20%, - rgba(251, 191, 36, 0.18), - transparent 28% - ), - radial-gradient( - circle at 20% 80%, - rgba(249, 115, 22, 0.16), - transparent 26% - ), - linear-gradient(180deg, #2d160e, #020617); - --puzzle-runtime-grid-line: rgba(255, 255, 255, 0.04); - --puzzle-runtime-text-strong: #ffffff; - --puzzle-runtime-text-base: rgba(255, 255, 255, 0.82); - --puzzle-runtime-text-soft: rgba(255, 255, 255, 0.58); - --puzzle-runtime-surface-fill: rgba(0, 0, 0, 0.34); - --puzzle-runtime-surface-fill-strong: rgba(0, 0, 0, 0.5); - --puzzle-runtime-surface-border: rgba(255, 255, 255, 0.12); - --puzzle-runtime-board-fill: rgba(255, 255, 255, 0.08); - --puzzle-runtime-board-border: rgba(255, 255, 255, 0.16); - --puzzle-runtime-board-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); - --puzzle-runtime-piece-fill: rgba(255, 255, 255, 0.12); - --puzzle-runtime-piece-border: transparent; - --puzzle-runtime-piece-empty-fill: rgba(0, 0, 0, 0.18); - --puzzle-runtime-piece-empty-text: rgba(255, 255, 255, 0.2); - --puzzle-runtime-piece-selected-fill: rgba(251, 191, 36, 0.84); - --puzzle-runtime-piece-selected-text: #020617; - --puzzle-runtime-piece-selected-border: transparent; - --puzzle-runtime-next-card-overlay: rgba(0, 0, 0, 0.1); - --puzzle-runtime-control-fill: rgba(0, 0, 0, 0.36); - --puzzle-runtime-control-hover-fill: rgba(255, 255, 255, 0.1); - --puzzle-runtime-primary-fill: #fde68a; - --puzzle-runtime-primary-text: #020617; - --puzzle-runtime-primary-shadow: 0 14px 36px rgba(251, 191, 36, 0.26); - --puzzle-runtime-accent-text: #fde68a; - --puzzle-runtime-cool-text: #cffafe; - --puzzle-runtime-danger-fill: rgba(239, 68, 68, 0.2); - --puzzle-runtime-danger-text: #fee2e2; - --puzzle-runtime-backdrop-fill: rgba(2, 6, 23, 0.68); - --puzzle-runtime-dialog-fill: radial-gradient( - circle at 14% 0%, - rgba(251, 191, 36, 0.16), - transparent 36% - ), - linear-gradient(180deg, rgba(29, 17, 12, 0.96), rgba(2, 6, 23, 0.96)); - --puzzle-runtime-dialog-border: rgba(251, 191, 36, 0.22); - --puzzle-runtime-table-fill: rgba(255, 255, 255, 0.06); - --puzzle-runtime-table-row-fill: rgba(251, 191, 36, 0.14); - --puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.06); - --puzzle-runtime-next-card-hover-fill: rgba(251, 191, 36, 0.1); -} .platform-brand-logo { display: inline-flex; diff --git a/src/index.test.ts b/src/index.test.ts index 9584033c9..412a98df0 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -7,6 +7,13 @@ function readIndexCss() { return fs.readFileSync(path.resolve(process.cwd(), 'src/index.css'), 'utf8'); } +function readPlatformThemeCss() { + return fs.readFileSync( + path.resolve(process.cwd(), 'packages/shared/src/theme.css'), + 'utf8', + ); +} + function getCssBlock(source: string, selector: string) { const selectorIndex = source.indexOf(selector); expect(selectorIndex, `${selector} should exist in src/index.css`).toBeGreaterThanOrEqual(0); @@ -194,11 +201,16 @@ describe('index stylesheet unread dots', () => { it('uses warm brown tokens for draft unread markers instead of red literals', () => { const css = readIndexCss(); + const themeCss = readPlatformThemeCss(); - expect(css).toContain('--platform-unread-dot-fill: #8b5a3d;'); - expect(css).toContain('--platform-unread-dot-glow: rgba(139, 90, 61, 0.34);'); - expect(css).toContain('--platform-unread-dot-fill: #d6a27c;'); - expect(css).toContain('--platform-unread-dot-glow: rgba(214, 162, 124, 0.24);'); + expect(themeCss).toContain('--platform-unread-dot-fill: #8b5a3d;'); + expect(themeCss).toContain( + '--platform-unread-dot-glow: rgba(139, 90, 61, 0.34);', + ); + expect(themeCss).toContain('--platform-unread-dot-fill: #d6a27c;'); + expect(themeCss).toContain( + '--platform-unread-dot-glow: rgba(214, 162, 124, 0.24);', + ); for (const selector of [ '.creation-work-card__unread-dot', @@ -213,6 +225,20 @@ describe('index stylesheet unread dots', () => { } }); + it('keeps platform font overrides after branded title typography', () => { + const css = readIndexCss(); + const themeCss = readPlatformThemeCss(); + const titleRuleIndex = css.indexOf('.selection-hero-brand__title'); + const platformThemeRuleIndex = css.indexOf('.platform-theme,\n.platform-theme *'); + + expect(titleRuleIndex).toBeGreaterThanOrEqual(0); + expect(platformThemeRuleIndex).toBeGreaterThan(titleRuleIndex); + expect(getCssBlock(css, '.platform-theme,\n.platform-theme *')).toContain( + 'font-family: var(--platform-font-family) !important;', + ); + expect(themeCss).not.toContain('.platform-theme,\n.platform-theme *'); + }); + it('keeps the creation shelf share button on a visible surface', () => { const css = readIndexCss(); const block = getCssBlock(css, '.creation-work-card__quick-action-button'); From c70a33d682b8d4d61e453728bca7807b40ea20b0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 18:09:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=B8=8E=E6=B8=A0=E9=81=93=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退款 observation ID 按来源与事实指纹稳定区分 人工复核只豁免本次批准的订单冲突并持久化错误码 普通微信渠道在查单前拒绝虚拟支付、会员与非正式渠道 网络结果未知时当前弹窗直接复用原请求继续核对 按 release 首次上线基线移除未发布退款结构兼容并同步文档与绑定 --- .../src/pages/AdminRechargeOrderPage.test.tsx | 13 +- .../src/pages/AdminRechargeOrderPage.tsx | 1 + .../shared-memory/decision-log.md | 3 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 10 +- .../crates/api-server/src/admin_recharge.rs | 128 +++++++++++++++--- .../profile_recharge_refund_type.rs | 7 + .../crates/spacetime-module/src/migration.rs | 64 --------- .../spacetime-module/src/runtime/profile.rs | 70 ++++++++-- 8 files changed, 193 insertions(+), 103 deletions(-) diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 92224d082..450949062 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -274,7 +274,7 @@ test('已登记的部分退款允许在 REFUND 支付状态下继续退剩余额 ).toBe(false); }); -test('网络结果不明后重新预检会复用稳定 requestId 且双击只提交一次', async () => { +test('网络结果不明后当前弹窗直接复用稳定 requestId', async () => { const user = userEvent.setup(); vi.mocked(executeAdminRechargeRefund) .mockRejectedValueOnce(new Error('network disconnected')) @@ -284,21 +284,26 @@ test('网络结果不明后重新预检会复用稳定 requestId 且双击只提 await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); await user.type(screen.getByLabelText('退款原因'), '用户申请'); await user.click(screen.getByRole('button', { name: '核验支付账单' })); - await user.dblClick(screen.getByRole('button', { name: '确认退款' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1); const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock .calls[0]?.[1].requestId; - await user.click(screen.getByRole('button', { name: '核验支付账单' })); - await user.dblClick(screen.getByRole('button', { name: '确认退款' })); + expect( + screen + .getByRole('button', { name: '核验支付账单' }) + .hasAttribute('disabled'), + ).toBe(true); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); await waitFor(() => expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), ); expect( vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1].requestId, ).toBe(firstRequestId); + expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); }); test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => { diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 5ad5a1a25..26a5f32d7 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -345,6 +345,7 @@ export function AdminRechargeOrderPage({ if (!isAdminApiError(error)) { setProviderStatusUnknown(true); setPreview(null); + setResumableRefundRequest(requestContext); setRefundError(unknownProviderStatusMessage); } else { removePersistedRefundRequestContext(token, refundOrder.orderId); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8f5ace7b2..04a67ec11 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4081,7 +4081,8 @@ - 背景:普通 V3 退款已经能从回调、查单和账单收口现金事实,但后台主动退款若先调微信再扣泥点,会在用户余额不足时产生本可避免的欠账;后台各页面也没有统一查询用户余额、绑定状态和充值订单的入口。 - 决策:新增 `profile_recharge_refund_hold`,后台执行退款前按累计部分退款公式原子占用本次应追回的永久泥点,再使用客户端稳定 `requestId` 派生 `out_refund_no` 调微信。部分退款额外占用 1 泥点并发舍入缓冲,防止占用创建后到达的外部退款跨越累计 `floor` 边界;全额退款不加缓冲。`SUCCESS` 扣款并结算匹配占用,`CLOSED` 释放,`PROCESSING / ABNORMAL` 保持;结果未知时不擅自释放,至少等待 10 分钟并连续 3 次退款查单收到官方 `RESOURCE_NOT_EXISTS` 才由 worker 释放。普通消费必须排除全部活动占用。 - 欠账与冻结:支付侧已经成功退款时不能回滚现金事实;永久泥点不足部分继续只写订单 settlement 的 `unrecovered_points`,限制普通消费,后续永久泥点优先自动偿还。每日免费和会员周期泥点不参与。人工冻结单独使用 `profile_wallet_manual_restriction`,解除人工冻结不解除退款欠账限制。 -- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间后重新运行标准结算;该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。 +- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间和获批错误码后重新运行标准结算;一次确认只豁免对应冲突,其他不变量继续 fail-closed。该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。 +- 发布基线:退款功能和退款事实表尚未进入 release,首次上线只以 release 已有 schema 和数据为迁移基线;不为 master 上未发布的退款中间结构保留旧行 normalization 或历史人工复核兼容。 - 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。 - 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。 - 影响范围:`module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 管理员 BFF / refund worker、`shared-contracts` 与 `apps/admin-web`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 95588882a..0796c8489 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -203,13 +203,13 @@ npm run check:server-rs-ddd 13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods` 从 `status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。 -16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return`。`out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;重复 observation 必须校验订单、交易、金额、状态和指纹,不允许仅按主键直接吞掉冲突。 +16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return`。`out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;退款申请响应和主动查单等生产者生成 observation ID 时必须同时纳入来源和事实指纹,同一来源同一事实稳定重放、不同来源不得复用 ID;重复 observation 必须校验订单、交易、金额、状态、来源和指纹,不允许仅按主键直接吞掉冲突。 17. `profile_recharge_order_refund_settlement` 按原订单聚合累计成功退款金额和权益回收。部分退款不改充值订单 `paid`;累计金额等于订单金额时才改为 `refunded`。历史支付和首充资格以 `paid_at` 是否存在判断,退款不把用户重新变成首充。 -18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因和时间后重新运行标准结算,能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。 +18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因、时间和本次获批的错误码,再重新运行标准结算;一次确认只豁免对应的交易号或订单总额冲突,渠道、支付状态及其他未获批冲突继续 fail-closed。能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。 19. 外部现金退款 `SUCCESS` 必须先持久化并 ACK,即使本地订单缺失、金额冲突、权益不足或会员需要人工处理,也不能回滚已经发生的现金事实。冲突 observation 记录 resolution code 并进入告警;只有验签、解密、契约解析或 SpacetimeDB 持久化失败才让微信重试。 20. 主动查询与退款交易账单 worker 只由 HTTP 角色运行并由 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true` 显式开启。生产 env 示例和 API deploy 会为缺失配置补 `true`;当 `WECHAT_PAY_ENABLED=true + WECHAT_PAY_PROVIDER=real` 时显式关闭该开关必须阻断发布,启动日志也必须明确记录启用或异常关闭状态。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞同日其他退款或其他日期,但该日不写完成 checkpoint 并继续重试;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。账单申请响应验签,GZIP 解压后按 SHA1 验真,CSV 用结构化 parser 和十进制定点金额解析;发现手工退款后必须再查单取得当前状态。 21. 普通 V3 支付通知同时校验 AppID、商户号、本地订单渠道、金额和微信支付单号;`success_time` 缺失或非法时拒绝,不能用本机时间补齐。晚到通知遇到 `refunded` 订单只做交易号一致性幂等校验,不再次发放权益。 -22. 后台主动退款只支持普通 V3 泥点订单。`api-server` 先做微信支付订单查单预检,再调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。虚拟支付、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须 fail-closed。 +22. 后台主动退款只支持 `wechat_mp`、`wechat_jsapi`、`wechat_h5`、`wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。 23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。 24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。 25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,并调用 runtime service identity 受限 procedure;前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。 @@ -762,14 +762,14 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileRechargeRefund` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因和处理时间;三字段只写一次,作为重新执行正式结算的审计事实。数据库迁移导入旧退款行时,缺失的三个审计字段必须补 `null`,保证升级前冷备可恢复。 +- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。 - 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_status_updated_at`。 ### `profile_recharge_refund_observation` - Rust 结构体:`ProfileRechargeRefundObservation` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、稳定 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 +- 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、按“来源 + 事实指纹”稳定生成的 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;同一事实从不同来源进入时使用不同 ID,不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 - 索引:`by_profile_recharge_refund_observation_out_refund_no`、`by_profile_recharge_refund_observation_order_id`。 ### `profile_recharge_order_refund_settlement` diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 289510f95..f4ec6c325 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -8,8 +8,11 @@ use axum::{ }; use module_auth::AuthUser; use module_runtime::{ - RuntimeProfileAdminWalletSnapshot, RuntimeProfileRechargeOrderAdminEntrySnapshot, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeRefundHoldSnapshot, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot, + RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot, RuntimeProfileRechargeRefundStatus, build_runtime_profile_admin_wallet_get_input, @@ -255,6 +258,8 @@ pub async fn admin_execute_recharge_refund( let out_refund_no = build_out_refund_no(&payload.order_id, request_id); let refund_reason = normalize_refund_reason(payload.reason.as_deref()); let mut entry = load_order(&state, &request_context, &payload.order_id).await?; + validate_refund_provider_route(&entry) + .map_err(|error| error_response(&request_context, error))?; let has_matching_hold = entry.active_hold.as_ref().is_some_and(|hold| { hold.out_refund_no == out_refund_no && hold.refund_cents == payload.refund_amount_cents }); @@ -540,6 +545,8 @@ async fn reconcile_and_validate_order( Response, > { let mut entry = load_order(state, request_context, &order_id).await?; + validate_refund_provider_route(&entry) + .map_err(|error| error_response(request_context, error))?; let mut payment_check = query_and_validate_payment(state, &entry) .await .map_err(|error| error_response(request_context, error))?; @@ -688,10 +695,7 @@ async fn persist_refund_fact( ) -> Result<(), SpacetimeClientError> { let fingerprint = refund_fact_fingerprint(refund); let observation = build_wechat_pay_refund_observation( - format!( - "admin-refund-observation-{}", - short_hash(fingerprint.as_bytes()) - ), + admin_refund_observation_id(source, &fingerprint), source, None, fingerprint, @@ -728,11 +732,10 @@ fn validate_refund_amount( fn order_refund_block_reason( entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Option<&'static str> { - if entry.order.kind.as_str() != "points" { - return Some("membership_not_supported"); - } - if !is_ordinary_wechat_v3_channel(&entry.order.payment_channel) { - return Some("payment_channel_not_supported"); + if let Some(reason_code) = + refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) + { + return Some(reason_code); } if !matches!( entry.order.status, @@ -1125,13 +1128,53 @@ fn short_hash(value: &[u8]) -> String { hex::encode(&Sha256::digest(value)[..8]) } +fn admin_refund_observation_id( + source: RuntimeProfileRechargeRefundObservationSource, + fingerprint: &str, +) -> String { + format!( + "admin-refund-observation-{}-{}", + source.as_str(), + short_hash(fingerprint.as_bytes()) + ) +} + fn is_ordinary_wechat_v3_channel(value: &str) -> bool { matches!( value, - "wechat_mini_program" | "wechat_jsapi" | "wechat_h5" | "wechat_native" + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) } +fn refund_provider_route_block_reason( + kind: RuntimeProfileRechargeProductKind, + payment_channel: &str, +) -> Option<&'static str> { + if kind != RuntimeProfileRechargeProductKind::Points { + return Some("membership_not_supported"); + } + if !is_ordinary_wechat_v3_channel(payment_channel) { + return Some("payment_channel_not_supported"); + } + None +} + +fn validate_refund_provider_route( + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> Result<(), AppError> { + if let Some(reason_code) = + refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) + { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_message(refund_block_message(reason_code)) + .with_details(json!({"reasonCode": reason_code}))); + } + Ok(()) +} + fn classify_refund_block_reason(message: &str) -> &'static str { if message.contains("永久泥点不足") || message.contains("可用") { "insufficient_permanent_points" @@ -1195,13 +1238,17 @@ mod tests { body::Body, http::{Request, StatusCode}, }; - use module_runtime::RuntimeProfileRechargeRefundHoldStatus; + use module_runtime::{ + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldStatus, + RuntimeProfileRechargeRefundObservationSource, + }; use tower::ServiceExt; use super::{ - build_out_refund_no, classify_refund_block_reason, is_likely_wechat_refund_id, - normalize_request_id, payment_check_block_reason_code, - payment_trade_state_allows_additional_refund, refund_block_message, + admin_refund_observation_id, build_out_refund_no, classify_refund_block_reason, + is_likely_wechat_refund_id, is_ordinary_wechat_v3_channel, normalize_request_id, + payment_check_block_reason_code, payment_trade_state_allows_additional_refund, + refund_block_message, refund_provider_route_block_reason, validate_prepared_refund_hold_status, }; use crate::{app::build_router, config::AppConfig, state::AppState}; @@ -1229,6 +1276,55 @@ mod tests { assert!(!is_likely_wechat_refund_id("refund-1001")); } + #[test] + fn admin_refund_observation_ids_are_stable_per_source_and_fact() { + let fingerprint = "sha256:refund-fact"; + let api_request = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::ApiRequest, + fingerprint, + ); + let repeated_api_request = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::ApiRequest, + fingerprint, + ); + let query = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::Query, + fingerprint, + ); + + assert_eq!(api_request, repeated_api_request); + assert_ne!(api_request, query); + } + + #[test] + fn refund_provider_route_uses_canonical_payment_channels() { + assert!(is_ordinary_wechat_v3_channel("wechat_mp")); + assert!(is_ordinary_wechat_v3_channel("wechat_jsapi")); + assert!(!is_ordinary_wechat_v3_channel("wechat_mini_program")); + assert!(!is_ordinary_wechat_v3_channel("wechat_mp_virtual")); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Points, + "wechat_mp" + ), + None + ); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Points, + "wechat_mp_virtual" + ), + Some("payment_channel_not_supported") + ); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Membership, + "wechat_native" + ), + Some("membership_not_supported") + ); + } + #[test] fn reconciled_partial_refund_keeps_the_remaining_amount_refundable() { assert!(payment_trade_state_allows_additional_refund( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs index b71253bba..9eeba7cd5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs @@ -36,6 +36,7 @@ pub struct ProfileRechargeRefund { pub manual_review_resolved_by_admin_user_id: Option, pub manual_review_resolution_reason: Option, pub manual_review_resolved_at: Option<__sdk::Timestamp>, + pub manual_review_resolved_error_code: Option, } impl __sdk::InModule for ProfileRechargeRefund { @@ -82,6 +83,8 @@ pub struct ProfileRechargeRefundCols { __sdk::__query_builder::Col>, pub manual_review_resolved_at: __sdk::__query_builder::Col>, + pub manual_review_resolved_error_code: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRechargeRefund { @@ -137,6 +140,10 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeRefund { table_name, "manual_review_resolved_at", ), + manual_review_resolved_error_code: __sdk::__query_builder::Col::new( + table_name, + "manual_review_resolved_error_code", + ), } } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 96a0506c6..c67a80267 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1345,20 +1345,6 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } - if table_name == "profile_recharge_refund" { - if let Some(object) = next_value.as_object_mut() { - // 中文注释:退款人工复核审计字段晚于退款事实表加入,旧迁移包按尚未处理兼容。 - object - .entry("manual_review_resolved_by_admin_user_id".to_string()) - .or_insert(serde_json::Value::Null); - object - .entry("manual_review_resolution_reason".to_string()) - .or_insert(serde_json::Value::Null); - object - .entry("manual_review_resolved_at".to_string()) - .or_insert(serde_json::Value::Null); - } - } if table_name == "external_generation_job_summary" { if let Some(object) = next_value.as_object_mut() { // 中文注释:非阻断完成告警晚于轻量任务摘要表加入,旧迁移包按无告警兼容。 @@ -1676,56 +1662,6 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["warning_message"], serde_json::Value::Null); } - #[test] - fn old_profile_recharge_refund_rows_import_with_empty_manual_review_audit() { - let row = ProfileRechargeRefund { - out_refund_no: "refund-legacy".to_string(), - provider_refund_id: "wx-refund-legacy".to_string(), - order_id: "order-legacy".to_string(), - provider_transaction_id: "wx-transaction-legacy".to_string(), - user_id: Some("user-legacy".to_string()), - provider_status: RuntimeProfileRechargeRefundStatus::Success, - total_cents: 600, - refund_cents: 300, - payer_total_cents: 600, - payer_refund_cents: 300, - success_at: Some(Timestamp::from_micros_since_unix_epoch(200)), - first_observed_at: Timestamp::from_micros_since_unix_epoch(100), - updated_at: Timestamp::from_micros_since_unix_epoch(200), - last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query, - last_observation_id: "observation-legacy".to_string(), - order_settled_at: None, - target_recovery_points: 30, - recovered_points: 30, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Applied, - last_recovery_ledger_id: Some("ledger-legacy".to_string()), - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - }; - let mut legacy_value = row_to_json(&row).expect("退款旧行应可序列化"); - let object = legacy_value - .as_object_mut() - .expect("退款迁移行应为 JSON object"); - object.remove("manual_review_resolved_by_admin_user_id"); - object.remove("manual_review_resolution_reason"); - object.remove("manual_review_resolved_at"); - - let normalized = normalize_migration_row("profile_recharge_refund", &legacy_value); - let restored = row_from_json::( - "profile_recharge_refund", - &normalized, - &mut Vec::new(), - ) - .expect("缺少人工复核字段的旧退款行应可导入"); - - assert!(restored.manual_review_resolved_by_admin_user_id.is_none()); - assert!(restored.manual_review_resolution_reason.is_none()); - assert!(restored.manual_review_resolved_at.is_none()); - } - #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 706139824..678980d8d 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -512,6 +512,8 @@ pub struct ProfileRechargeRefund { pub(crate) manual_review_resolution_reason: Option, #[default(None::)] pub(crate) manual_review_resolved_at: Option, + #[default(None::)] + pub(crate) manual_review_resolved_error_code: Option, } #[spacetimedb::table( @@ -2727,6 +2729,7 @@ mod tests { manual_review_resolved_by_admin_user_id: None, manual_review_resolution_reason: None, manual_review_resolved_at: None, + manual_review_resolved_error_code: None, } } @@ -2751,7 +2754,7 @@ mod tests { } #[test] - fn confirmed_manual_review_suppresses_only_the_order_match_conflict() { + fn confirmed_manual_review_suppresses_only_the_approved_order_match_conflict() { let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); let mut refund = recharge_refund_for_order(&order); refund.provider_transaction_id = "wx-transaction-other".to_string(); @@ -2761,6 +2764,8 @@ mod tests { ); refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); + refund.manual_review_resolved_error_code = + Some("provider_transaction_id_mismatch".to_string()); assert_eq!( unresolved_profile_recharge_refund_order_match_error(&order, &refund), None @@ -2769,6 +2774,18 @@ mod tests { validate_profile_recharge_refund_order_match(&order, &refund), Err("provider_transaction_id_mismatch".to_string()) ); + + refund.total_cents = order.amount_cents + 1; + assert_eq!( + unresolved_profile_recharge_refund_order_match_error(&order, &refund), + Some("order_total_mismatch".to_string()) + ); + + refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); + assert_eq!( + unresolved_profile_recharge_refund_order_match_error(&order, &refund), + Some("provider_transaction_id_mismatch".to_string()) + ); } #[test] @@ -4617,10 +4634,10 @@ fn resolve_profile_recharge_refund_manual_review( } let review_code = refund .last_error_code - .as_deref() + .clone() .ok_or_else(|| "退款缺少人工复核原因".to_string())?; if !matches!( - review_code, + review_code.as_str(), "provider_transaction_id_mismatch" | "order_total_mismatch" ) { return Err(format!( @@ -4636,10 +4653,20 @@ fn resolve_profile_recharge_refund_manual_review( if order.kind != RuntimeProfileRechargeProductKind::Points { return Err("会员退款不能使用泥点钱包人工复核入口".to_string()); } + if let Some(remaining_code) = profile_recharge_refund_order_match_error_except( + &order, + &refund, + Some(review_code.as_str()), + ) { + return Err(format!( + "确认当前退款冲突后仍存在未获批准的订单不匹配: {remaining_code}" + )); + } refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id); refund.manual_review_resolution_reason = Some(validated.reason); refund.manual_review_resolved_at = Some(ctx.timestamp); + refund.manual_review_resolved_error_code = Some(review_code); refund.updated_at = ctx.timestamp; let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview { @@ -5041,6 +5068,7 @@ fn record_profile_recharge_refund_observation( manual_review_resolved_by_admin_user_id: None, manual_review_resolution_reason: None, manual_review_resolved_at: None, + manual_review_resolved_error_code: None, }, }; @@ -5211,10 +5239,19 @@ fn reconcile_profile_recharge_refund_success( (Some(settlement), resolution_code.to_string()) } +#[cfg(test)] fn validate_profile_recharge_refund_order_match( order: &ProfileRechargeOrder, refund: &ProfileRechargeRefund, ) -> Result<(), String> { + profile_recharge_refund_order_match_error_except(order, refund, None).map_or(Ok(()), Err) +} + +fn profile_recharge_refund_order_match_error_except( + order: &ProfileRechargeOrder, + refund: &ProfileRechargeRefund, + approved_error_code: Option<&str>, +) -> Option { if !matches!( order.payment_channel.as_str(), PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM @@ -5222,7 +5259,7 @@ fn validate_profile_recharge_refund_order_match( | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) { - return Err("payment_channel_not_v3".to_string()); + return Some("payment_channel_not_v3".to_string()); } if order.paid_at.is_none() || !matches!( @@ -5230,24 +5267,31 @@ fn validate_profile_recharge_refund_order_match( RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded ) { - return Err("order_not_paid".to_string()); + return Some("order_not_paid".to_string()); } - if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) { - return Err("provider_transaction_id_mismatch".to_string()); + if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) + && approved_error_code != Some("provider_transaction_id_mismatch") + { + return Some("provider_transaction_id_mismatch".to_string()); } - if order.amount_cents != refund.total_cents { - return Err("order_total_mismatch".to_string()); + if order.amount_cents != refund.total_cents + && approved_error_code != Some("order_total_mismatch") + { + return Some("order_total_mismatch".to_string()); } - Ok(()) + None } fn unresolved_profile_recharge_refund_order_match_error( order: &ProfileRechargeOrder, refund: &ProfileRechargeRefund, ) -> Option { - validate_profile_recharge_refund_order_match(order, refund) - .err() - .filter(|_| refund.manual_review_resolved_at.is_none()) + let approved_error_code = refund + .manual_review_resolved_at + .is_some() + .then_some(refund.manual_review_resolved_error_code.as_deref()) + .flatten(); + profile_recharge_refund_order_match_error_except(order, refund, approved_error_code) } fn mark_profile_recharge_refund_manual_review( From a597fcd076e5fccdc5df53a8ddb5246c0835e162 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 19:20:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=A4=8D=E6=A0=B8=E4=B8=8E=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐退款事实对比字段与人工复核审计展示 人工复核重放严格校验管理员、原因和错误码 退款占用重试完整匹配管理员与归一化原因 统一退款原因持久化上限并修复刷新恢复 补充前后端回归测试、生成绑定与数据契约文档 --- apps/admin-web/src/api/adminApiClient.test.ts | 38 ++++-- apps/admin-web/src/api/adminApiTypes.ts | 4 + .../src/pages/AdminRechargeOrderPage.test.tsx | 99 +++++++++++++- .../src/pages/AdminRechargeOrderPage.tsx | 93 ++++++++++++- .../shared-memory/decision-log.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- .../crates/api-server/src/admin_recharge.rs | 122 +++++++++++++++++- .../profile_recharge_refund_reconciliation.rs | 1 + .../crates/module-runtime/src/commands.rs | 27 ++++ server-rs/crates/module-runtime/src/domain.rs | 2 + .../crates/shared-contracts/src/admin.rs | 13 +- .../src/mapper/runtime_profile.rs | 2 + ...refund_manual_review_resolve_input_type.rs | 1 + ...e_profile_recharge_refund_snapshot_type.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 101 +++++++++++++++ 15 files changed, 480 insertions(+), 36 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index b2127d332..a673e0e01 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -1,4 +1,4 @@ -import {afterEach, expect, test, vi} from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import { executeAdminRechargeRefund, @@ -13,9 +13,9 @@ afterEach(() => { test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({entries: []}), { + new Response(JSON.stringify({ entries: [] }), { status: 200, - headers: {'content-type': 'application/json'}, + headers: { 'content-type': 'application/json' }, }), ); vi.stubGlobal('fetch', fetchMock); @@ -47,12 +47,14 @@ test('充值订单查询按后台契约序列化筛选参数', async () => { }); test('用户详情只发送实际提供的用户定位字段', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({userId: 'user-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }), + ); vi.stubGlobal('fetch', fetchMock); - await getAdminUserDetail('token-1', {publicUserCode: 'TN1001'}); + await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' }); const requestUrl = String(fetchMock.mock.calls[0]?.[0]); const parsed = new URL(requestUrl, 'http://admin.local'); @@ -63,9 +65,13 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); test('退款执行使用独立 execute 管理员路由', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await executeAdminRechargeRefund('token-1', { @@ -92,14 +98,19 @@ test('退款执行使用独立 execute 管理员路由', async () => { }); test('退款人工复核使用独立 resolve 管理员路由', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), - ); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await resolveAdminRechargeRefundManualReview('token-1', { outRefundNo: 'refund-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: 'provider_transaction_id_mismatch', }); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( @@ -111,6 +122,7 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => { body: JSON.stringify({ outRefundNo: 'refund-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: 'provider_transaction_id_mismatch', }), }), ); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1afe7f04f..31afc2d78 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -785,7 +785,9 @@ export interface AdminProfileWalletPayload { export interface AdminRechargeRefundPayload { outRefundNo: string; providerRefundId: string; + providerTransactionId: string; providerStatus: string; + totalCents: number; refundCents: number; payerRefundCents: number; successAtMicros?: number | null; @@ -800,6 +802,7 @@ export interface AdminRechargeRefundPayload { manualReviewResolvedByAdminUserId?: string | null; manualReviewResolutionReason?: string | null; manualReviewResolvedAtMicros?: number | null; + manualReviewResolvedErrorCode?: string | null; } export interface AdminRechargeRefundHoldPayload { @@ -882,6 +885,7 @@ export interface AdminRechargeRefundRegisterRequest { export interface AdminRechargeRefundManualReviewResolveRequest { outRefundNo: string; reason: string; + expectedErrorCode: string; } export interface AdminWalletRestrictionRequest { diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 450949062..81e4fcbde 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -355,6 +355,56 @@ test('刷新后根据 active hold 恢复原退款请求并复用 requestId', asy expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); }); +test('超长退款原因按服务端上限持久化并可在刷新后恢复', async () => { + const user = userEvent.setup(); + const normalizedReason = '退'.repeat(80); + vi.mocked(executeAdminRechargeRefund) + .mockRejectedValueOnce(new Error('network disconnected')) + .mockResolvedValueOnce(actionFor(baseOrder)); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), `${normalizedReason}多`); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock + .calls[0]?.[1].requestId; + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[0]?.[1].reason).toBe( + normalizedReason, + ); + firstRender.unmount(); + + const activeHoldOrder = orderWithActiveHold(); + activeHoldOrder.activeHold = { + ...activeHoldOrder.activeHold!, + reason: normalizedReason, + }; + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [activeHoldOrder], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '退款处理' })).toBeTruthy(); + expect(screen.getByLabelText('退款原因')).toHaveProperty( + 'value', + normalizedReason, + ); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); + + await waitFor(() => + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), + ); + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + reason: normalizedReason, + requestId: firstRequestId, + }), + ); +}); + test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => { const user = userEvent.setup(); vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( @@ -474,7 +524,16 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( entries: [ { ...baseOrder, - refunds: [manualReviewRefund({ lastErrorCode })], + refunds: [ + manualReviewRefund({ + lastErrorCode, + providerTransactionId: + lastErrorCode === 'provider_transaction_id_mismatch' + ? 'wx-transaction-other' + : 'wx-transaction-1', + totalCents: lastErrorCode === 'order_total_mismatch' ? 700 : 600, + }), + ], }, ], }); @@ -485,6 +544,29 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( const submitButton = within(dialog).getByRole('button', { name: '确认并追回', }); + expect( + within(dialog).getByText( + lastErrorCode === 'provider_transaction_id_mismatch' + ? '微信交易单号不一致' + : '订单总额不一致', + ), + ).toBeTruthy(); + expect( + within(dialog).getByText('本地微信交易单号').parentElement?.textContent, + ).toContain('wx-transaction-1'); + expect( + within(dialog).getByText('退款微信交易单号').parentElement?.textContent, + ).toContain( + lastErrorCode === 'provider_transaction_id_mismatch' + ? 'wx-transaction-other' + : 'wx-transaction-1', + ); + expect( + within(dialog).getByText('本地订单总额').parentElement?.textContent, + ).toContain('¥6.00'); + expect( + within(dialog).getByText('退款订单总额').parentElement?.textContent, + ).toContain(lastErrorCode === 'order_total_mismatch' ? '¥7.00' : '¥6.00'); expect(submitButton.hasAttribute('disabled')).toBe(true); await user.type( within(dialog).getByRole('textbox', { name: '人工复核原因' }), @@ -498,6 +580,7 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( { outRefundNo: 'refund-manual-1', reason: '已核对微信商户平台原始账单', + expectedErrorCode: lastErrorCode, }, ); }); @@ -516,7 +599,12 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async ...baseOrder, orderId: 'order-resolved', refunds: [ - manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }), + manualReviewRefund({ + manualReviewResolvedAtMicros: 1_720_000, + manualReviewResolvedByAdminUserId: 'admin-1', + manualReviewResolutionReason: '已核对商户平台', + manualReviewResolvedErrorCode: 'provider_transaction_id_mismatch', + }), ], }, { @@ -531,6 +619,10 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async await screen.findByText('order-membership'); expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull(); + expect(screen.getByText(/复核 微信交易单号不一致/).textContent).toContain( + 'admin-1', + ); + expect(screen.getByText(/已核对商户平台/)).toBeTruthy(); }); test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => { @@ -650,7 +742,9 @@ function manualReviewRefund( return { outRefundNo: 'refund-manual-1', providerRefundId: 'provider-refund-1', + providerTransactionId: 'wx-transaction-other', providerStatus: 'SUCCESS', + totalCents: 600, refundCents: 300, payerRefundCents: 300, successAtMicros: 1_720_000_000_000_000, @@ -665,6 +759,7 @@ function manualReviewRefund( manualReviewResolvedByAdminUserId: null, manualReviewResolutionReason: null, manualReviewResolvedAtMicros: null, + manualReviewResolvedErrorCode: null, ...overrides, }; } diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 26a5f32d7..d1f4848b0 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -59,7 +59,7 @@ interface PersistedRefundRequestContext { } interface ManualReviewTarget { - orderId: string; + order: AdminRechargeOrderEntryPayload; refund: AdminRechargeRefundPayload; } @@ -277,7 +277,7 @@ export function AdminRechargeOrderPage({ ) { return; } - const reason = refundReason.trim(); + const reason = normalizeRefundReason(refundReason); if (!reason) { setRefundError('请填写退款原因'); return; @@ -403,7 +403,7 @@ export function AdminRechargeOrderPage({ order: AdminRechargeOrderEntryPayload, refund: AdminRechargeRefundPayload, ) { - setManualReviewTarget({ orderId: order.orderId, refund }); + setManualReviewTarget({ order, refund }); setManualReviewReason(''); setManualReviewError(''); } @@ -411,15 +411,23 @@ export function AdminRechargeOrderPage({ async function handleResolveManualReview(event: FormEvent) { event.preventDefault(); const reason = manualReviewReason.trim(); - if (!manualReviewTarget || manualReviewLockRef.current || !reason) { + const expectedErrorCode = manualReviewTarget?.refund.lastErrorCode?.trim(); + if ( + !manualReviewTarget || + manualReviewLockRef.current || + !reason || + !expectedErrorCode + ) { if (!reason) { setManualReviewError('请填写人工复核原因'); + } else if (!expectedErrorCode) { + setManualReviewError('退款人工复核状态已变化,请刷新后重试'); } return; } const confirmed = await confirmWrite({ action: '确认微信退款并追回泥点', - target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`, + target: `${manualReviewTarget.order.orderId} / ${manualReviewTarget.refund.outRefundNo}`, }); if (!confirmed || manualReviewLockRef.current) { return; @@ -432,6 +440,7 @@ export function AdminRechargeOrderPage({ const response = await resolveAdminRechargeRefundManualReview(token, { outRefundNo: manualReviewTarget.refund.outRefundNo, reason, + expectedErrorCode, }); setLastAction(response); upsertOrder(response.order); @@ -773,6 +782,7 @@ export function AdminRechargeOrderPage({ setRefundReason(event.target.value)} /> @@ -967,6 +977,36 @@ export function AdminRechargeOrderPage({