拆分兑换凭证纯函数与结果写入

This commit is contained in:
yml
2026-05-04 13:19:05 +08:00
parent 58f25fad74
commit 37c9dfd119
7 changed files with 467 additions and 89 deletions
@@ -0,0 +1,9 @@
export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1'
export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
export const BEIJING_TIME_PROOF_CLIP = {
x: 0,
y: 0,
width: BEIJING_TIME_PROOF_VIEWPORT.width,
height: 620,
}
export const DEFAULT_REDEEM_PROOF_MODE = 'full'
@@ -0,0 +1,30 @@
// @ts-nocheck
import { runtimeConfig } from '../../config/runtime.js'
import { DEFAULT_REDEEM_PROOF_MODE } from './session-proof-constants.js'
export function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) {
const normalized = String(rawMode || '')
.trim()
.toLowerCase()
if (normalized === 'basic' || normalized === 'off') {
return normalized
}
return DEFAULT_REDEEM_PROOF_MODE
}
export function shouldCaptureBeijingTimeProof(finalResult) {
if (finalResult?.classification?.success === true) {
return true
}
if (finalResult?.classification?.success === false) {
return false
}
const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret)
return Number.isFinite(retCode) && retCode === 0
}
@@ -0,0 +1,11 @@
import path from 'node:path'
export function buildArtifactPaths(outputDir) {
return {
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
screenshotPath: path.join(outputDir, 'redeem-result.png'),
htmlPath: path.join(outputDir, 'page.html'),
resultPath: path.join(outputDir, 'result.json'),
}
}
@@ -0,0 +1,47 @@
// @ts-nocheck
import fs from 'node:fs/promises'
import { BAIDU_BEIJING_TIME_URL } from './session-proof-constants.js'
export async function writeRedeemResultFile(
resultPath,
{
proofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath = '',
redeemPageScreenshotPath = '',
beijingTimeScreenshotPath = '',
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
beijingTimeError = '',
},
) {
await fs.writeFile(
resultPath,
JSON.stringify(
{
proofMode,
sessionId,
activityUrl,
code,
area: finalResult?.role?.area || '',
final: finalResult,
attempts,
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
beijingTimePageUrl,
beijingTimeError,
screenshotPath,
htmlPath,
},
null,
2,
),
'utf8',
)
}
@@ -1,19 +1,16 @@
// @ts-nocheck
import fs from 'node:fs/promises'
import path from 'node:path'
import { runtimeConfig } from '../../config/runtime.js'
const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1'
const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 }
const BEIJING_TIME_PROOF_CLIP = {
x: 0,
y: 0,
width: BEIJING_TIME_PROOF_VIEWPORT.width,
height: 620,
}
const DEFAULT_REDEEM_PROOF_MODE = 'full'
import {
BAIDU_BEIJING_TIME_URL,
BEIJING_TIME_PROOF_CLIP,
BEIJING_TIME_PROOF_VIEWPORT,
DEFAULT_REDEEM_PROOF_MODE,
} from './session-proof-constants.js'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js'
import { buildArtifactPaths } from './session-proof-paths.js'
import { writeRedeemResultFile } from './session-proof-result-writer.js'
export async function saveRedeemArtifacts({
browserContext,
@@ -145,82 +142,7 @@ export async function writeRedeemArtifactFiles({
}
}
export function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) {
const normalized = String(rawMode || '')
.trim()
.toLowerCase()
if (normalized === 'basic' || normalized === 'off') {
return normalized
}
return DEFAULT_REDEEM_PROOF_MODE
}
export function shouldCaptureBeijingTimeProof(finalResult) {
if (finalResult?.classification?.success === true) {
return true
}
if (finalResult?.classification?.success === false) {
return false
}
const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret)
return Number.isFinite(retCode) && retCode === 0
}
function buildArtifactPaths(outputDir) {
return {
redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'),
beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'),
screenshotPath: path.join(outputDir, 'redeem-result.png'),
htmlPath: path.join(outputDir, 'page.html'),
resultPath: path.join(outputDir, 'result.json'),
}
}
async function writeRedeemResultFile(
resultPath,
{
proofMode,
sessionId,
activityUrl,
code,
finalResult,
attempts,
screenshotPath,
htmlPath = '',
redeemPageScreenshotPath = '',
beijingTimeScreenshotPath = '',
beijingTimePageUrl = BAIDU_BEIJING_TIME_URL,
beijingTimeError = '',
},
) {
await fs.writeFile(
resultPath,
JSON.stringify(
{
proofMode,
sessionId,
activityUrl,
code,
area: finalResult?.role?.area || '',
final: finalResult,
attempts,
redeemPageScreenshotPath,
beijingTimeScreenshotPath,
beijingTimePageUrl,
beijingTimeError,
screenshotPath,
htmlPath,
},
null,
2,
),
'utf8',
)
}
export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof }
async function showResultDialog(page, message) {
await page.evaluate((text) => {
@@ -1,7 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import path from 'node:path'
import { shouldCaptureBeijingTimeProof } from './session-proof.js'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js'
import { buildArtifactPaths } from './session-proof-paths.js'
test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => {
assert.equal(resolveRedeemProofMode('basic'), 'basic')
assert.equal(resolveRedeemProofMode(' OFF '), 'off')
assert.equal(resolveRedeemProofMode('full'), 'full')
assert.equal(resolveRedeemProofMode('unexpected'), 'full')
assert.equal(resolveRedeemProofMode(''), 'full')
})
test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => {
assert.equal(
@@ -42,3 +52,13 @@ test('shouldCaptureBeijingTimeProof only returns true for successful redeem resu
assert.equal(shouldCaptureBeijingTimeProof(null), false)
})
test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => {
const artifactPaths = buildArtifactPaths('/tmp/redeem-proof')
assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png'))
assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png'))
assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png'))
assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html'))
assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json'))
})
+339
View File
@@ -0,0 +1,339 @@
# 前后端现状分析
## 当前结论
整体架构方向没有跑偏,后端主干仍然是 `routes -> services -> repositories`,前端也已经逐步从“巨石页面”往“薄页面 + composable + 子组件”推进。
现在最需要继续优化的,不是全项目平均整理,而是两类高风险区域:
1. 已经明显长成巨石、后续改动容易连锁的后端运行时模块
2. 已经完成第一轮拆分、但还需要收口和补测试的前端后台配置模块
## 已完成
### 平台配置页前端拆分
这部分已经不再是最初判断里的“一个页面承载四套完整平台流”。
- `8e1a4be` 拆分前端后台管理 API 客户端
- `apps/frontend/src/services/admin.ts` 已改成聚合入口
- 后台 API 已按领域拆到 `services/admin/` 目录
- `dff7da7` 拆分平台配置页脚本逻辑
- `AdminPlatformShopsView.vue` 的平台状态和行为已经拆到多个 composable
- 已有:
- `useAdminAgisoPlatform.ts`
- `useAdminKhhaoPlatform.ts`
- `useAdminKuaishouEticketPlatform.ts`
- `useAdminCloudtentaclesPlatform.ts`
- `4650c3e` 抽离平台配置页共享样式
- 共享样式已统一收口到 `apps/frontend/src/styles/admin-platform-shops.css`
- `60c2d91` 拆分平台配置页 Agiso 与 khhao 区块
- `254a031` 拆分平台配置页快手核销区块
- `8f6a72f` 拆分平台配置页履约调试区块
- 四个平台区块已经分别下沉为独立组件:
- `AdminPlatformAgisoSection.vue`
- `AdminPlatformKhhaoSection.vue`
- `AdminPlatformKuaishouEticketSection.vue`
- `AdminPlatformCloudtentaclesSection.vue`
- `58f25fa` 抽离平台配置结果信息卡片
- 新增 `AdminResultCard.vue`
- `khhao` / `快手核销` / `cloudtentacles` 的重复结果卡片已统一
### 现阶段对平台配置页的重新判断
`apps/frontend/src/views/admin/AdminPlatformShopsView.vue` 仍然不算小,但问题性质已经变了:
- 现在主要问题不再是“大模板混杂”
- 现在的主要问题是:
- 父页面 `script setup` 仍然偏胖
- 几个子组件之间还有少量可复用片段
- 前端缺少测试防线
也就是说,这条线的第一阶段“拆分大页面”已经基本完成,后面不应该继续无限抽象,而应该适时收口。
## 已过时的判断
下面这些结论需要更新,不再按最初版本理解:
### `AdminPlatformShopsView.vue` 是前端第一优先级
这个判断已经部分过时。
原因:
- 该页面的四套平台模板已经完成拆分
- 平台行为已经移动到 composable
- 剩余问题主要是“收口”和“测试”,不是继续大拆
新判断:
- 这条线仍可继续小步优化
- 但优先级已经低于后端的 `session.js``session-proof.js``admin-platform-config-service.js`
### 继续深挖前端平台配置页
这个方向不应该再作为主战场。
剩余可做项有,但应该控制强度:
- 可以继续抽 `section-title-row``meta-card` 这一层公共片段
- 但不能为了统一而统一
- 再往下更应该转向后端高风险链路
## 仍然优先拆分 / 优先优化
### 1. `apps/backend/src/services/session/session.js`
后端第一优先级,判断不变。
它同时承担:
- Playwright 浏览器生命周期
- 会话内存状态
- 二维码抓取
- 登录态判断
- 角色信息同步
- 兑换执行
- 截图产物
- 自动关闭
而且仍然是 `@ts-nocheck`。这类文件每继续增长一次,后面改动成本都会上升。
建议目标:
- `browser-runtime`
- `session-store`
- `session-presentation`
- `session-redeem-orchestrator`
- `session-persistence`
### 2. `apps/backend/src/services/session/session-proof.js`
这是下一步最合适的切入点。
当前职责混在一起:
- 证明模式判定
- 产物路径拼接
- 结果页 DOM 注入
- 北京时间来源抓取
- 截图拼接
- 结果 JSON 落盘
这类文件比 `session.js` 更小,适合作为后端拆分的第一刀,风险更低,能先把拆分节奏跑顺。
建议拆分方向:
- `proof-mode`
- 处理 `resolveRedeemProofMode`
- 处理 `shouldCaptureBeijingTimeProof`
- `proof-paths`
- 处理 `buildArtifactPaths`
- `proof-result-writer`
- 处理 `writeRedeemResultFile`
- `proof-beijing-time`
- 处理北京时间页面抓取与截图
- `proof-renderer`
- 处理结果弹层、合成图、截图表现逻辑
先保留 `session-proof.js` 作为门面导出层,第一阶段不要改公开接口。
### 3. `apps/backend/src/services/admin/admin-platform-config-service.js`
这条线依然偏胖,而且和前端平台配置页是一整条竖线。
虽然前端页面已经拆开,但后端这条 service 仍然同时承载:
- 平台配置读写
- 登录测试
- 订单查询
- SKU 调试
- 虚拟号调试
- 履约绑定
建议后续按平台拆为:
- `agiso-admin-service`
- `khhao-admin-service`
- `kuaishou-eticket-admin-service`
- `cloudtentacles-admin-service`
同时把“配置读写”和“调试接口”分层处理。
### 4. `apps/backend/src/services/claim/claim-session-service.js`
仍然是后续重点。
问题主要在:
- 管理态 / 用户态创建逻辑平行复制
- claim token、浏览器会话、任务同步、库存释放、自动发货收尾混在一起
建议方向:
- `claim-context`
- `claim-session-lifecycle`
- `claim-task-sync`
- `claim-fulfillment-finalizer`
### 5. `apps/backend/src/services/admin/admin-write-service.js`
依然偏胖,但优先级略低于上面三项。
它更像“后台万能动作总线”,跨域过多:
- 库存
- claim
- webhook
- cloudtentacles
- 快手核销
- 腾讯 session
建议后续至少拆成:
- `admin-inventory-write`
- `admin-task-actions`
- `admin-webhook-actions`
- `admin-manual-redeem-actions`
### 6. `apps/backend/src/services/webhook/webhook-service.js`
仍然适合改成流水线。
建议拆成:
- `webhook-parse`
- `webhook-verify`
- `webhook-ignore-policy`
- `webhook-enrich`
- `webhook-process`
- `webhook-replay`
### 7. `apps/backend/src/config/runtime.js`
仍需要“配置声明化”优化。
目前问题不是功能错,而是长期可维护性差:
- env override 越堆越长
- 新平台配置接入成本越来越高
- 难测、难查、难对照
建议后期改成:
- 配置 schema
- env 映射表
- 默认值与来源声明
## 质量防线现状
这个判断仍然成立,而且重要性上升了。
当前风险:
- 后端高风险模块仍然存在 `@ts-nocheck`
- 前端后台配置页最近已经连续多次重构
- 目前主要依靠 `typecheck + build` 防回归
这能挡住:
- 类型问题
- 组件引用问题
- 构建问题
但挡不住:
- 交互退化
- 条件渲染错误
- 任务状态流转异常
- 外部平台调试链路断裂
建议至少补两层最小防线:
1. 前端
- 平台配置页基础渲染 smoke 测试
- 关键按钮存在性 / 条件渲染测试
2. 后端
- `session-proof.js` 纯函数单测
- 快手 cloud / claim / task 状态流转的集成 smoke 测试
## 暂时不用急着拆
### 腾讯浏览器前端链路
- `TencentBrowserView.vue`
- `session-qq.js`
- 对应 `wx` 变体
这些区域当前模式反而比较健康,后续可以作为其它模块拆分时的参照。
## 下一步计划
### 已完成阶段
1. 拆分前端后台管理 API 客户端
2. 拆分平台配置页脚本逻辑
3. 抽离平台配置页共享样式
4. 拆分 Agiso / khhao / 快手核销 / cloudtentacles 四个平台区块
5. 抽离结果信息卡片
### 下一阶段
#### Phase 1:从 `session-proof.js` 开始
目标:
- 先拆一个比 `session.js` 更小、更收敛的后端高风险文件
- 建立后端拆分模板
- 不改公开接口,不改产物格式,不改调用方
建议步骤:
1. 抽纯函数
- proof mode
- proof path
- result writer
2. 抽副作用模块
- 北京时间截图采集
- 证明图合成
3. 保留门面文件
- `session-proof.js` 暂时继续导出原接口
4. 每个小阶段都跑校验并单独提交
#### Phase 2:再处理 `session.js`
前提:
- `session-proof.js` 拆分完成
- 对这条运行时链路已经熟悉
#### Phase 3:回到平台配置后端竖线
重点转向:
- `admin-platform-config-service.js`
- 相关 route
- 相关调试接口
## 当前建议
前端平台配置页这条线可以暂时收口,不建议再继续大规模抽模板。
下一步最合适的是:
1. 开始拆 `apps/backend/src/services/session/session-proof.js`
2. 同时补一层最小测试防线
如果继续执行,下一刀就从 `session-proof.js` 开始。