开始多平台 多店铺整改

This commit is contained in:
yml
2026-04-08 22:21:11 +08:00
parent d5b10e3f7f
commit a2b09c89e8
40 changed files with 1750 additions and 685 deletions
+29
View File
@@ -0,0 +1,29 @@
APP_DOMAIN=926d-193-176-84-20.ngrok-free.app
APP_BASE_URL=https://926d-193-176-84-20.ngrok-free.app
CADDY_SITE_ADDR=http://926d-193-176-84-20.ngrok-free.app
# 当前模板用于 mac 本机 Docker 测试,并通过 ngrok 转发到 localhost:80。
TZ=Asia/Shanghai
BACKEND_PORT=3000
CLAIM_BASE_URL=https://926d-193-176-84-20.ngrok-free.app/#/claim
DATABASE_FILE_PATH=/app/data/order-site.db
OCR_PROJECT_ROOT=/app/subservices/ocr-worker
TENCENT_REDEEM_PROOF_MODE=basic
TENCENT_BROWSER_HEADLESS=true
TENCENT_BROWSER_DEVTOOLS=false
TENCENT_BROWSER_PREWARM=true
TENCENT_BROWSER_KEEP_ALIVE=true
TENCENT_BROWSER_SLOW_MO=0
TENCENT_SESSION_DEBUG=false
ADMIN_SESSION_SECRET=dev-local-session-secret
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
ORDER_SKU_MAPPINGS_JSON={"32768":"dnf-cdk-a","dnf-cdk-a":"dnf-cdk-a"}
AGISO_APP_SECRET=
AGISO_MESSAGING_ENABLED=false
AGISO_APP_ID=
AGISO_ACCESS_TOKEN=
AGISO_MESSAGE_APP_SECRET=
AGISO_MESSAGE_TEMPLATE=您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}
+3
View File
@@ -1,14 +1,17 @@
APP_DOMAIN=order.example.com
APP_BASE_URL=https://order.example.com
TZ=Asia/Shanghai
BACKEND_PORT=3000
CLAIM_BASE_URL=https://order.example.com/#/claim
DATABASE_FILE_PATH=/app/data/order-site.db
OCR_PROJECT_ROOT=/app/subservices/ocr-worker
TENCENT_REDEEM_PROOF_MODE=basic
TENCENT_BROWSER_HEADLESS=true
TENCENT_BROWSER_DEVTOOLS=false
TENCENT_BROWSER_PREWARM=true
TENCENT_BROWSER_KEEP_ALIVE=true
TENCENT_BROWSER_SLOW_MO=0
TENCENT_SESSION_DEBUG=false
ADMIN_SESSION_SECRET=replace-with-a-long-random-secret
+1 -1
View File
@@ -8,12 +8,12 @@ apps/*/dist
apps/*/.vite
apps/*/.env.local
apps/backend/config/local.cjs
apps/backend/data/*.db
apps/backend/data/*.db-shm
apps/backend/data/*.db-wal
apps/backend/data/browser-sessions
apps/backend/data/redeem-screenshots
apps/backend/data/logs
.ace-tool
.claude
+40 -7
View File
@@ -28,28 +28,53 @@ npm run dev
后端:
```bash
cp .env.development.example .env
cd apps/backend/subservices/ocr-worker
uv sync
cd apps/backend
npm install
cd subservices/ocr-worker
uv sync
cd ../..
cp config/local.example.cjs config/local.cjs
npm run dev
```
## Docker 部署
```bash
cp .env.example .env
cp .env.production.example .env
docker compose build
docker compose up -d
```
## Docker 本地开发
```bash
cp .env.development.example .env
docker compose -f docker-compose.dev.yml up -d --build
```
开发版 Compose 现在是热更新模式:
- 后端源码挂载到容器里,运行 `npm run dev`
- 前端源码挂载到容器里,运行 `vite`
- Caddy 只负责把 `80/443` 反代到前后端容器
通常只有首次启动、改 Dockerfile、改系统依赖时才需要 `--build`
开发版 Compose 会把后端数据目录直接挂载到:
- [apps/backend/data](/Users/yml/codes/order-site-workspace/apps/backend/data)
这样本地可以直接看到:
- SQLite 数据库
- `data/logs/*.log`
- 浏览器会话产物
- 截图和证明文件
当前 Dockerfile 已默认针对国内服务器优化以下下载源:
- Debian `apt` 使用腾讯云镜像
- `npm` 使用 `npmmirror`
- `uv` 通过 `pip` 安装,`uv` / `pip` 使用腾讯云 PyPI 镜像
- Python `pip` 使用腾讯云 PyPI 镜像
- 后端 Playwright 固定为 `1.42.1`,浏览器下载使用 `npmmirror`
如果你的服务器网络环境不同,也可以在构建时覆盖:
@@ -67,14 +92,22 @@ docker compose build \
- 后端健康检查:`https://你的域名/health`
- 后端接口前缀:`https://你的域名/api/v1/...`
开发时如果只改业务代码,直接保留 `docker compose -f docker-compose.dev.yml up -d` 即可:
- 改后端 `src/`:容器内自动热重启
- 改前端 `src/`Vite 自动热更新
- 改 OCR Python:后端下次调用时直接走挂载后的最新源码
详细文档:
- [服务器部署教程](/Users/yml/codes/order-site-workspace/docs/%E6%9C%8D%E5%8A%A1%E5%99%A8%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B.md)
- [首次上线检查清单](/Users/yml/codes/order-site-workspace/docs/%E9%A6%96%E6%AC%A1%E4%B8%8A%E7%BA%BF%E6%A3%80%E6%9F%A5%E6%B8%85%E5%8D%95.md)
- [生产环境变量模板.env](/Users/yml/codes/order-site-workspace/docs/%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E6%A8%A1%E6%9D%BF.env)
- [开发环境模板](/Users/yml/codes/order-site-workspace/.env.development.example)
- [生产环境模板](/Users/yml/codes/order-site-workspace/.env.production.example)
## 迁移原则
- 不重写现有业务代码
- 先统一目录、部署和配置
- 生产环境优先使用 `.env` 和 Docker 卷
- 本机 Docker 调试优先使用 [docker-compose.dev.yml](/Users/yml/codes/order-site-workspace/docker-compose.dev.yml)
+1 -2
View File
@@ -6,10 +6,9 @@ node_modules
subservices/**/.venv/
__pycache__/
*.pyc
config/local.cjs
data/browser-sessions/
data/redeem-screenshots/
data/logs/
data/*.db
data/*.db-shm
data/*.db-wal
+33 -14
View File
@@ -20,14 +20,15 @@ npm install
首次使用前同步 OCR 子服务依赖:
```bash
cd /Users/yml/codes/order-site-backend/subservices/ocr-worker
cd /Users/yml/codes/order-site-workspace/apps/backend/subservices/ocr-worker
uv sync
```
回到项目根目录启动:
复制并填写环境变量后启动:
```bash
cd /Users/yml/codes/order-site-backend
cp /Users/yml/codes/order-site-workspace/.env.development.example /Users/yml/codes/order-site-workspace/.env
cd /Users/yml/codes/order-site-workspace/apps/backend
npm run dev
```
@@ -39,19 +40,12 @@ npm run browser:install:linux
npm run start
```
## 配置文件
## 配置
现在推荐直接改配置文件,不需要每次在终端里手动拼环境变量。
当前后端采用 `.env` 单一路径做环境配置:
- 默认配置`config/default.cjs`
- 本地覆盖:`config/local.cjs`
- 参考模板:`config/local.example.cjs`
推荐做法:
1. 通用默认值放在 `config/default.cjs`
2. 你自己机器上的配置放在 `config/local.cjs`
3. 只有 CI、部署脚本、临时排查时再用环境变量覆盖
- 通用默认值`config/default.cjs`
- 本地/部署覆盖:工作区根目录 `.env`
当前最常改的配置有:
@@ -91,6 +85,20 @@ npm run start
- `subservices/ocr-worker`
内嵌 OCR 子服务
## Docker 开发
当前 `docker-compose.dev.yml` 已切到源码挂载模式:
- 后端容器启动时执行 `npm install``npm run dev`
- OCR 子服务会在容器里执行 `pip install -e /app/subservices/ocr-worker`
- 所以平时改 `src/`、改 OCR Python、改 `.env`,一般都不需要重建镜像
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`
- `deploy/docker/backend.Dockerfile`
- 系统层依赖
- Playwright 浏览器基础环境
## 产物
浏览器会话产物默认保存在:
@@ -104,6 +112,17 @@ npm run start
这些都是运行时产物,默认不提交 Git。
运行日志默认保存在:
- `data/logs/app.log`
- `data/logs/webhook.log`
OCR 子服务已经简化为:
- 本地源码开发:在 `subservices/ocr-worker` 里执行一次 `uv sync`
- Docker 运行:镜像构建时直接安装 Python 依赖
- 后端调用:每次识别单独拉起一次 Python 进程,不维护常驻 OCR worker
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
- `full`:完整证明
-85
View File
@@ -1,85 +0,0 @@
module.exports = {
server: {
// 例如:本机改成 3100
port: 3000,
},
browser: {
// 例如:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
chromePath: '',
// 例如:本机调试时可改成 false
headless: null,
devtools: null,
keepAlive: null,
prewarm: null,
slowMoMs: null,
},
session: {
// 需要排查问题时可临时改成 true
debug: false,
},
ocr: {
// 如果 OCR 子服务目录不是仓库默认位置,就在这里覆盖
projectRoot: '',
},
database: {
// 如果想把 SQLite 放到别的位置,就在这里覆盖
filePath: '',
},
orders: {
// 用户领取页基础地址,例如:https://your-domain.com/#/claim
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
tokenTtlHours: 24,
skuMappings: {
// "32768": "dnf-cdk-a",
},
},
admin: {
// 后台 session 签名密钥,至少 32 位随机串
sessionSecret: 'change-this-session-secret',
sessionTtlHours: 12,
defaultUsers: [
{
username: 'admin',
password: 'change-this-admin-password',
role: 'admin',
},
{
username: 'operator',
password: 'change-this-operator-password',
role: 'operator',
},
],
},
platforms: {
agiso: {
// Agiso 回调验签密钥
appSecret: '',
messaging: {
enabled: false,
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
apiVersion: '1',
authMode: 'bearer',
appId: '',
accessToken: '',
appSecret: '',
messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}',
},
},
},
redeem: {
// 可选:full / basic / off
proofMode: 'full',
},
}
+2 -1
View File
@@ -6,8 +6,9 @@
"scripts": {
"browser:install": "playwright install chromium",
"browser:install:linux": "playwright install --with-deps chromium",
"cleanup:dev-data": "node scripts/cleanup-dev-data.js",
"db:migrate": "node src/db/migrate.js",
"dev": "node --watch src/index.js",
"dev": "node --watch-path=src --watch-path=config --watch-preserve-output src/index.js",
"start": "node src/index.js"
},
"dependencies": {
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { DatabaseSync } from 'node:sqlite'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
const DEFAULT_DB_PATH = path.join(PROJECT_ROOT, 'data', 'order-site.db')
const PRESETS = {
'codex-debug': {
platformOrderIds: ['DEBUG-ORDER-001', '2067719225654999', '2067719225655000', '2067719225655001'],
shopIds: ['debug-shop', 'shop-10001', 'shop-10002', 'shop-10003'],
},
}
const options = parseArgs(process.argv.slice(2))
if (options.help) {
printHelp()
process.exit(0)
}
const preset = options.preset ? PRESETS[options.preset] : null
if (options.preset && !preset) {
console.error(`未知 preset: ${options.preset}`)
printHelp()
process.exit(1)
}
const selectors = {
provider: options.provider,
platform: options.platform,
orderIds: normalizeNumberList(options.orderIds),
platformOrderIds: uniqueList([...(preset?.platformOrderIds || []), ...options.platformOrderIds]),
shopIds: uniqueList([...(preset?.shopIds || []), ...options.shopIds]),
shopNames: uniqueList(options.shopNames),
}
if (!hasAnySelector(selectors)) {
console.error('至少需要一个筛选条件,或使用 --preset codex-debug')
printHelp()
process.exit(1)
}
const dbPath = resolveDbPath(options.dbPath)
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA foreign_keys = ON;')
db.exec('PRAGMA busy_timeout = 3000;')
const matched = collectMatches(db, selectors)
printSummary(dbPath, selectors, matched, options.apply)
if (!options.apply) {
console.log('\n当前为预览模式。确认后追加 `--apply` 执行删除。')
process.exit(0)
}
db.exec('BEGIN')
try {
deleteByIds(db, 'message_deliveries', matched.messageDeliveryIds)
deleteByIds(db, 'webhook_events', matched.webhookEventIds)
deleteByIds(db, 'orders', matched.orderIds)
db.exec('COMMIT')
console.log('\n清理完成。若后台服务正在运行,建议重启一次 backend 容器,确保管理后台视图立即刷新。')
} catch (error) {
db.exec('ROLLBACK')
console.error('\n清理失败:', error instanceof Error ? error.message : String(error || '未知错误'))
process.exit(1)
}
function collectMatches(db, selectors) {
const orderWhere = buildOrderWhere(selectors)
const orders = db.prepare(`
SELECT id, provider, platform, shop_id, shop_name, platform_order_id
FROM orders
${orderWhere.sql}
ORDER BY id DESC
`).all(...orderWhere.params)
const orderIds = orders.map((item) => Number(item.id))
const taskIds = orderIds.length > 0
? db.prepare(`
SELECT id
FROM delivery_tasks
WHERE order_id IN (${placeholders(orderIds.length)})
ORDER BY id DESC
`).all(...orderIds).map((item) => Number(item.id))
: []
const webhookWhere = buildWebhookWhere(selectors, orderIds)
const webhookEvents = db.prepare(`
SELECT id, provider, platform, shop_id, shop_name, event_type, event_key, related_order_id
FROM webhook_events
${webhookWhere.sql}
ORDER BY id DESC
`).all(...webhookWhere.params)
const messageWhere = buildMessageWhere(selectors, orderIds, taskIds)
const messageDeliveries = db.prepare(`
SELECT id, provider, platform, shop_id, shop_name, platform_order_id, status
FROM message_deliveries
${messageWhere.sql}
ORDER BY id DESC
`).all(...messageWhere.params)
return {
orders,
orderIds,
taskIds,
webhookEvents,
webhookEventIds: webhookEvents.map((item) => Number(item.id)),
messageDeliveries,
messageDeliveryIds: messageDeliveries.map((item) => Number(item.id)),
}
}
function buildOrderWhere(selectors) {
const baseFilters = []
const matchFilters = []
const params = []
const matchParams = []
if (selectors.provider) {
baseFilters.push('provider = ?')
params.push(selectors.provider)
}
if (selectors.platform) {
baseFilters.push('platform = ?')
params.push(selectors.platform)
}
if (selectors.orderIds.length > 0) {
matchFilters.push(`id IN (${placeholders(selectors.orderIds.length)})`)
matchParams.push(...selectors.orderIds)
}
if (selectors.platformOrderIds.length > 0) {
matchFilters.push(`platform_order_id IN (${placeholders(selectors.platformOrderIds.length)})`)
matchParams.push(...selectors.platformOrderIds)
}
if (selectors.shopIds.length > 0) {
matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`)
matchParams.push(...selectors.shopIds)
}
if (selectors.shopNames.length > 0) {
matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`)
matchParams.push(...selectors.shopNames)
}
const filters = [...baseFilters]
if (matchFilters.length > 0) {
filters.push(`(${matchFilters.join(' OR ')})`)
params.push(...matchParams)
}
return {
sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
}
}
function buildWebhookWhere(selectors, orderIds) {
const baseFilters = []
const matchFilters = []
const params = []
const matchParams = []
if (selectors.provider) {
baseFilters.push('provider = ?')
params.push(selectors.provider)
}
if (selectors.platform) {
baseFilters.push('platform = ?')
params.push(selectors.platform)
}
if (orderIds.length > 0) {
matchFilters.push(`related_order_id IN (${placeholders(orderIds.length)})`)
matchParams.push(...orderIds)
}
if (selectors.shopIds.length > 0) {
matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`)
matchParams.push(...selectors.shopIds)
}
if (selectors.shopNames.length > 0) {
matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`)
matchParams.push(...selectors.shopNames)
}
if (selectors.platformOrderIds.length > 0) {
const eventKeyFilters = selectors.platformOrderIds.map(() => 'event_key LIKE ?').join(' OR ')
const bodyFilters = selectors.platformOrderIds.map(() => 'body_json LIKE ?').join(' OR ')
matchFilters.push(`(${eventKeyFilters})`)
matchParams.push(...selectors.platformOrderIds.map((item) => `%${item}%`))
matchFilters.push(`(${bodyFilters})`)
matchParams.push(...selectors.platformOrderIds.map((item) => `%${item}%`))
}
const filters = [...baseFilters]
if (matchFilters.length > 0) {
filters.push(`(${matchFilters.join(' OR ')})`)
params.push(...matchParams)
}
return {
sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
}
}
function buildMessageWhere(selectors, orderIds, taskIds) {
const baseFilters = []
const matchFilters = []
const params = []
const matchParams = []
if (selectors.provider) {
baseFilters.push('provider = ?')
params.push(selectors.provider)
}
if (selectors.platform) {
baseFilters.push('platform = ?')
params.push(selectors.platform)
}
if (orderIds.length > 0) {
matchFilters.push(`order_id IN (${placeholders(orderIds.length)})`)
matchParams.push(...orderIds)
}
if (taskIds.length > 0) {
matchFilters.push(`task_id IN (${placeholders(taskIds.length)})`)
matchParams.push(...taskIds)
}
if (selectors.shopIds.length > 0) {
matchFilters.push(`shop_id IN (${placeholders(selectors.shopIds.length)})`)
matchParams.push(...selectors.shopIds)
}
if (selectors.shopNames.length > 0) {
matchFilters.push(`shop_name IN (${placeholders(selectors.shopNames.length)})`)
matchParams.push(...selectors.shopNames)
}
if (selectors.platformOrderIds.length > 0) {
matchFilters.push(`platform_order_id IN (${placeholders(selectors.platformOrderIds.length)})`)
matchParams.push(...selectors.platformOrderIds)
}
const filters = [...baseFilters]
if (matchFilters.length > 0) {
filters.push(`(${matchFilters.join(' OR ')})`)
params.push(...matchParams)
}
return {
sql: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
params,
}
}
function deleteByIds(db, tableName, ids) {
if (!Array.isArray(ids) || ids.length === 0) {
return
}
db.prepare(`DELETE FROM ${tableName} WHERE id IN (${placeholders(ids.length)})`).run(...ids)
}
function resolveDbPath(overridePath) {
const candidates = [
String(overridePath || '').trim(),
DEFAULT_DB_PATH,
].filter(Boolean)
for (const candidate of candidates) {
const resolved = path.resolve(candidate)
if (fs.existsSync(resolved)) {
return resolved
}
}
console.error(`未找到数据库文件。默认查找路径: ${DEFAULT_DB_PATH}`)
console.error('可使用 `--db-path <path>` 手动指定。')
process.exit(1)
}
function parseArgs(argv) {
const output = {
help: false,
apply: false,
dbPath: '',
preset: '',
provider: '',
platform: '',
orderIds: [],
platformOrderIds: [],
shopIds: [],
shopNames: [],
}
for (let index = 0; index < argv.length; index += 1) {
const current = String(argv[index] || '').trim()
if (!current) {
continue
}
if (current === '--help' || current === '-h') {
output.help = true
continue
}
if (current === '--apply') {
output.apply = true
continue
}
if (current === '--db-path') {
output.dbPath = String(argv[index + 1] || '').trim()
index += 1
continue
}
if (current === '--preset') {
output.preset = String(argv[index + 1] || '').trim()
index += 1
continue
}
if (current === '--provider') {
output.provider = String(argv[index + 1] || '').trim()
index += 1
continue
}
if (current === '--platform') {
output.platform = String(argv[index + 1] || '').trim()
index += 1
continue
}
if (current === '--order-id') {
output.orderIds.push(String(argv[index + 1] || '').trim())
index += 1
continue
}
if (current === '--platform-order-id') {
output.platformOrderIds.push(String(argv[index + 1] || '').trim())
index += 1
continue
}
if (current === '--shop-id') {
output.shopIds.push(String(argv[index + 1] || '').trim())
index += 1
continue
}
if (current === '--shop-name') {
output.shopNames.push(String(argv[index + 1] || '').trim())
index += 1
continue
}
console.error(`未知参数: ${current}`)
printHelp()
process.exit(1)
}
return output
}
function printSummary(dbPath, selectors, matched, apply) {
console.log(`数据库: ${dbPath}`)
console.log(`模式: ${apply ? '执行删除' : '预览'}`)
console.log('筛选条件:')
console.log(JSON.stringify(selectors, null, 2))
console.log('\n匹配结果:')
console.log(`orders: ${matched.orders.length}`)
printRows(matched.orders, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'platform_order_id'])
console.log(`delivery_tasks: ${matched.taskIds.length}`)
console.log(`webhook_events: ${matched.webhookEvents.length}`)
printRows(matched.webhookEvents, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'event_type', 'related_order_id'])
console.log(`message_deliveries: ${matched.messageDeliveries.length}`)
printRows(matched.messageDeliveries, ['id', 'provider', 'platform', 'shop_id', 'shop_name', 'platform_order_id', 'status'])
}
function printRows(rows, keys) {
if (!Array.isArray(rows) || rows.length === 0) {
return
}
const preview = rows.slice(0, 10).map((row) => Object.fromEntries(keys.map((key) => [key, row[key]])))
console.log(JSON.stringify(preview, null, 2))
}
function placeholders(count) {
return new Array(count).fill('?').join(', ')
}
function uniqueList(values) {
return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))]
}
function normalizeNumberList(values) {
return uniqueList(values)
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item > 0)
}
function hasAnySelector(selectors) {
return Boolean(
selectors.provider ||
selectors.platform ||
selectors.orderIds.length > 0 ||
selectors.platformOrderIds.length > 0 ||
selectors.shopIds.length > 0 ||
selectors.shopNames.length > 0,
)
}
function printHelp() {
console.log(`
用法:
node scripts/cleanup-dev-data.js [筛选条件] [--apply]
常用参数:
--platform-order-id <id> 按平台订单号删除,可重复传入
--shop-id <id> 按店铺 ID 删除,可重复传入
--shop-name <name> 按店铺名删除,可重复传入
--order-id <id> 按内部订单 ID 删除,可重复传入
--provider <name> 限定 provider,例如 agiso
--platform <name> 限定业务平台,例如 xianyu
--db-path <path> 手动指定数据库文件
--preset codex-debug 清理当前对话里生成的调试样例
--apply 真正执行删除;不传时只预览
示例:
npm run cleanup:dev-data -- --platform-order-id DEBUG-ORDER-001
npm run cleanup:dev-data -- --shop-id shop-10003 --apply
npm run cleanup:dev-data -- --preset codex-debug --apply
`)
}
+1 -2
View File
@@ -2,8 +2,7 @@
set -euo pipefail
BASE_URL="http://127.0.0.1:3000"
CONFIG_FILE="/Users/yml/codes/order-site-backend/config/local.cjs"
APP_SECRET=$(node -p "require('$CONFIG_FILE').platforms.agiso.appSecret")
APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))")
ORDER_ID="${1:-SIM-ORDER-20260408-0001}"
SKU_ID="32768"
+59 -4
View File
@@ -7,13 +7,17 @@ import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
const CONFIG_ROOT = path.join(PROJECT_ROOT, 'config')
const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))
const localConfig = loadConfig(path.join(CONFIG_ROOT, 'local.cjs'))
const mergedConfig = deepMerge(defaultConfig, localConfig)
loadEnvFiles([
path.join(WORKSPACE_ROOT, '.env'),
path.join(PROJECT_ROOT, '.env'),
])
export const runtimeConfig = applyEnvOverrides(mergedConfig)
const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))
export const runtimeConfig = applyEnvOverrides(defaultConfig)
function loadConfig(configPath) {
if (!fs.existsSync(configPath)) {
@@ -24,6 +28,57 @@ function loadConfig(configPath) {
return isPlainObject(loaded) ? loaded : {}
}
function loadEnvFiles(filePaths) {
for (const filePath of filePaths) {
loadEnvFile(filePath)
}
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return
}
const rawText = fs.readFileSync(filePath, 'utf8')
const lines = rawText.split(/\r?\n/)
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) {
continue
}
const separatorIndex = line.indexOf('=')
if (separatorIndex <= 0) {
continue
}
const key = line.slice(0, separatorIndex).trim()
if (!key || key in process.env) {
continue
}
const value = parseEnvValue(line.slice(separatorIndex + 1))
process.env[key] = value
}
}
function parseEnvValue(rawValue) {
const value = String(rawValue || '').trim()
if (!value) {
return ''
}
const quote = value[0]
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
return value.slice(1, -1)
}
return value
}
function applyEnvOverrides(baseConfig) {
const nextConfig = deepMerge(baseConfig, {})
@@ -0,0 +1,94 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE IF NOT EXISTS orders__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'agiso',
platform TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
shop_name TEXT NOT NULL DEFAULT '',
platform_order_id TEXT NOT NULL,
order_status TEXT NOT NULL DEFAULT 'created',
pay_status TEXT NOT NULL DEFAULT 'unpaid',
buyer_id TEXT NOT NULL DEFAULT '',
buyer_name TEXT NOT NULL DEFAULT '',
receiver_contact TEXT NOT NULL DEFAULT '',
total_amount INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CNY',
raw_payload_json TEXT NOT NULL DEFAULT '{}',
paid_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO orders__new (
id,
provider,
platform,
shop_id,
shop_name,
platform_order_id,
order_status,
pay_status,
buyer_id,
buyer_name,
receiver_contact,
total_amount,
currency,
raw_payload_json,
paid_at,
created_at,
updated_at
)
SELECT
id,
'agiso',
platform,
'',
'',
platform_order_id,
order_status,
pay_status,
buyer_id,
buyer_name,
receiver_contact,
total_amount,
currency,
raw_payload_json,
paid_at,
created_at,
updated_at
FROM orders;
DROP TABLE orders;
ALTER TABLE orders__new RENAME TO orders;
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_provider_platform_shop_order
ON orders(provider, platform, shop_id, platform_order_id);
ALTER TABLE webhook_events ADD COLUMN provider TEXT NOT NULL DEFAULT 'agiso';
ALTER TABLE webhook_events ADD COLUMN shop_id TEXT NOT NULL DEFAULT '';
ALTER TABLE webhook_events ADD COLUMN shop_name TEXT NOT NULL DEFAULT '';
UPDATE webhook_events
SET provider = CASE
WHEN trim(provider) = '' THEN 'agiso'
ELSE provider
END;
CREATE INDEX IF NOT EXISTS idx_webhook_events_provider_platform_shop
ON webhook_events(provider, platform, shop_id);
ALTER TABLE message_deliveries ADD COLUMN provider TEXT NOT NULL DEFAULT 'agiso';
ALTER TABLE message_deliveries ADD COLUMN shop_id TEXT NOT NULL DEFAULT '';
ALTER TABLE message_deliveries ADD COLUMN shop_name TEXT NOT NULL DEFAULT '';
UPDATE message_deliveries
SET provider = CASE
WHEN trim(provider) = '' THEN 'agiso'
ELSE provider
END;
CREATE INDEX IF NOT EXISTS idx_message_deliveries_provider_platform_shop
ON message_deliveries(provider, platform, shop_id);
PRAGMA foreign_keys = ON;
+14 -13
View File
@@ -9,6 +9,7 @@ import webhooksRouter from './routes/webhooks.js'
import { ensureAdminUsersBootstrapped } from './services/admin-auth-service.js'
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/ocr.js'
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session.js'
import { logError, logInfo, logWarn } from './utils/logger.js'
import tencentRouter from './routes/tencent.js'
const app = express()
@@ -47,7 +48,7 @@ app.use('/api/v1/claim', claimsRouter)
app.use('/api/v1/admin', adminRouter)
const server = app.listen(port, () => {
console.log(`order-site-backend listening on http://127.0.0.1:${port}`)
logInfo('[startup]', `order-site-backend listening on http://127.0.0.1:${port}`)
void bootstrapBrowser()
void bootstrapOcr()
})
@@ -57,34 +58,34 @@ async function bootstrapBrowser() {
const result = await warmupTencentBrowser()
if (!result.warmed) {
console.log('[startup] browser prewarm skipped')
logInfo('[startup]', 'browser prewarm skipped')
return
}
console.log('[startup] browser prewarm ready')
logInfo('[startup]', 'browser prewarm ready')
} catch (error) {
if (isMissingPlaywrightBrowserError(error)) {
const installCommand = resolveBrowserInstallCommand()
console.warn(`[startup] browser prewarm skipped: ${formatErrorMessage(error)}`)
console.warn(`[startup] 请先安装浏览器依赖: ${installCommand}`)
logWarn('[startup]', `browser prewarm skipped: ${formatErrorMessage(error)}`)
logWarn('[startup]', `请先安装浏览器依赖: ${installCommand}`)
if (String(runtimeConfig.browser.chromePath || '').trim()) {
console.warn('[startup] 当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在')
logWarn('[startup]', '当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在')
}
return
}
console.error('[startup] browser prewarm failed:', error)
logError('[startup]', 'browser prewarm failed', error)
}
}
async function bootstrapOcr() {
try {
await warmupLocalOcrWorker()
console.log('[startup] local OCR worker ready')
logInfo('[startup]', 'local OCR ready')
} catch (error) {
console.warn(`[startup] local OCR worker skipped: ${formatStartupError(error)}`)
logWarn('[startup]', `local OCR skipped: ${formatStartupError(error)}`)
}
}
@@ -131,18 +132,18 @@ async function shutdown(signal) {
}
shutdownStarted = true
console.log(`[shutdown] received ${signal}, closing browser sessions and HTTP server`)
logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`)
try {
await closeAllTencentBrowserSessions({ markClosed: true })
} catch (error) {
console.error('[shutdown] failed to close browser sessions:', error)
logError('[shutdown]', 'failed to close browser sessions', error)
}
try {
await closeLocalOcrWorker()
} catch (error) {
console.error('[shutdown] failed to close local OCR worker:', error)
logError('[shutdown]', 'failed to close local OCR worker', error)
}
if (!server.listening) {
@@ -152,7 +153,7 @@ async function shutdown(signal) {
await new Promise((resolve) => {
server.close((error) => {
if (error) {
console.error('[shutdown] failed to close HTTP server:', error)
logError('[shutdown]', 'failed to close HTTP server', error)
process.exitCode = 1
}
@@ -3,7 +3,10 @@ import { getDb } from '../db/client.js'
export function createMessageDelivery(input) {
const result = getDb().prepare(`
INSERT INTO message_deliveries (
provider,
platform,
shop_id,
shop_name,
channel,
order_id,
task_id,
@@ -21,9 +24,12 @@ export function createMessageDelivery(input) {
sent_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.provider,
input.platform,
input.shopId,
input.shopName,
input.channel,
input.orderId,
input.taskId,
+18 -4
View File
@@ -1,18 +1,21 @@
import { getDb } from '../db/client.js'
export function findOrderByPlatformOrderId(platform, platformOrderId) {
export function findOrderByPlatformOrderId({ provider = 'agiso', platform, shopId = '', platformOrderId }) {
return getDb().prepare(`
SELECT *
FROM orders
WHERE platform = ? AND platform_order_id = ?
WHERE provider = ? AND platform = ? AND shop_id = ? AND platform_order_id = ?
LIMIT 1
`).get(platform, platformOrderId) || null
`).get(provider, platform, shopId, platformOrderId) || null
}
export function createOrder(input) {
const result = getDb().prepare(`
INSERT INTO orders (
provider,
platform,
shop_id,
shop_name,
platform_order_id,
order_status,
pay_status,
@@ -25,9 +28,12 @@ export function createOrder(input) {
paid_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.provider,
input.platform,
input.shopId,
input.shopName,
input.platformOrderId,
input.orderStatus,
input.payStatus,
@@ -49,6 +55,10 @@ export function updateOrder(orderId, input) {
getDb().prepare(`
UPDATE orders
SET
provider = ?,
platform = ?,
shop_id = ?,
shop_name = ?,
order_status = ?,
pay_status = ?,
buyer_id = ?,
@@ -61,6 +71,10 @@ export function updateOrder(orderId, input) {
updated_at = ?
WHERE id = ?
`).run(
input.provider,
input.platform,
input.shopId,
input.shopName,
input.orderStatus,
input.payStatus,
input.buyerId,
@@ -3,7 +3,10 @@ import { getDb } from '../db/client.js'
export function createWebhookEvent(input) {
const result = getDb().prepare(`
INSERT INTO webhook_events (
provider,
platform,
shop_id,
shop_name,
event_type,
event_key,
signature_valid,
@@ -14,9 +17,12 @@ export function createWebhookEvent(input) {
process_error,
related_order_id,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.provider,
input.platform,
input.shopId,
input.shopName,
input.eventType,
input.eventKey,
input.signatureValid ? 1 : 0,
@@ -60,6 +66,7 @@ export function getWebhookEventById(eventId) {
export function listWebhookEvents({
page = 1,
pageSize = 20,
provider = '',
platform = '',
processed = '',
relatedOrderId = '',
@@ -70,6 +77,11 @@ export function listWebhookEvents({
const filters = []
const params = []
if (provider) {
filters.push('provider = ?')
params.push(provider)
}
if (platform) {
filters.push('platform = ?')
params.push(platform)
+32
View File
@@ -1,19 +1,51 @@
import { Router } from 'express'
import { processAgisoTradeWebhook } from '../services/webhook-service.js'
import { createRequestId, logWebhook } from '../utils/logger.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
router.post('/agiso/trade', async (req, res) => {
const requestId = createRequestId('wh')
const startedAt = Date.now()
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
ip: req.ip,
headers: req.headers,
query: req.query,
body: req.body,
})
try {
const data = await processAgisoTradeWebhook({
requestId,
headers: req.headers,
query: req.query,
body: req.body,
})
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result: data,
})
res.json(buildSuccessPayload(data, 'success'))
} catch (error) {
logWebhook(
'[webhooks/agiso/trade]',
'Agiso webhook 处理失败',
{
requestId,
durationMs: Date.now() - startedAt,
error,
},
{ level: 'error' },
)
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
}
})
+21 -1
View File
@@ -70,7 +70,10 @@ export function getAdminOrderDetail(orderId) {
return {
order: {
orderId: order.id,
provider: order.provider || 'agiso',
platform: order.platform,
shopId: order.shop_id || '',
shopName: order.shop_name || '',
platformOrderId: order.platform_order_id,
orderStatus: order.order_status,
payStatus: order.pay_status,
@@ -96,7 +99,10 @@ export function getAdminOrderDetail(orderId) {
tasks: tasks.map(mapAdminTaskSummary),
webhookEvents: webhookEvents.map((event) => ({
eventId: event.id,
provider: event.provider || 'agiso',
platform: event.platform,
shopId: event.shop_id || '',
shopName: event.shop_name || '',
eventType: event.event_type,
eventKey: event.event_key,
signatureValid: Boolean(event.signature_valid),
@@ -154,6 +160,10 @@ export function getAdminTaskDetail(taskId) {
order: order
? {
orderId: order.id,
provider: order.provider || 'agiso',
platform: order.platform,
shopId: order.shop_id || '',
shopName: order.shop_name || '',
platformOrderId: order.platform_order_id,
payStatus: order.pay_status,
orderStatus: order.order_status,
@@ -337,6 +347,7 @@ export function getAdminWebhookEvents(query = {}) {
const { items, total } = listWebhookEvents({
page,
pageSize,
provider: String(query.provider || '').trim(),
platform: String(query.platform || '').trim(),
processed: String(query.processed || '').trim(),
relatedOrderId: String(query.relatedOrderId || '').trim(),
@@ -347,7 +358,10 @@ export function getAdminWebhookEvents(query = {}) {
return {
items: items.map((item) => ({
eventId: item.id,
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shop_id || '',
shopName: item.shop_name || '',
eventType: item.event_type,
eventKey: item.event_key,
signatureValid: Boolean(item.signature_valid),
@@ -372,7 +386,10 @@ export function getAdminWebhookEventDetail(eventId) {
return {
eventId: event.id,
provider: event.provider || 'agiso',
platform: event.platform,
shopId: event.shop_id || '',
shopName: event.shop_name || '',
eventType: event.event_type,
eventKey: event.event_key,
signatureValid: Boolean(event.signature_valid),
@@ -396,7 +413,7 @@ export async function replayAdminWebhookEvent(eventId) {
})
}
if (event.platform !== 'agiso') {
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
throw createHttpError('当前只支持重放 agiso webhook', {
statusCode: 409,
errorCode: 'admin_webhook_replay_not_supported',
@@ -686,7 +703,10 @@ function mapAdminOrderListItem(item) {
return {
orderId: item.id,
provider: item.provider || 'agiso',
platform: item.platform,
shopId: item.shop_id || '',
shopName: item.shop_name || '',
platformOrderId: item.platform_order_id,
orderStatus: item.order_status,
payStatus: item.pay_status,
+4 -1
View File
@@ -44,7 +44,10 @@ export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, cla
})
const createdAt = nowIso()
const delivery = createMessageDelivery({
platform: 'agiso',
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel: AGISO_MESSAGE_CHANNEL,
orderId: order.id,
taskId: task.id,
+126 -142
View File
@@ -1,193 +1,177 @@
import { spawn } from 'node:child_process'
import readline from 'node:readline'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { runtimeConfig } from '../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
let workerPromise = null
let requestSequence = 0
const pendingRequests = new Map()
let stderrBuffer = []
export async function recognizeTencentCaptcha(payload) {
return callOcrWorker('recognize', payload)
return callOcrRequest('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload) {
return callOcrWorker('batch', payload, { timeoutMs: 180_000 })
return callOcrRequest('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupLocalOcrWorker() {
await ensureOcrWorker()
await runOcrCommand('healthcheck', { timeoutMs: 30_000 })
}
export async function closeLocalOcrWorker() {
const worker = await workerPromise?.catch(() => null)
if (!worker) {
workerPromise = null
return
}
worker.child.kill()
workerPromise = null
// OCR 改为按次调用 Python 子进程,这里不再维护常驻 worker。
}
async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const worker = await ensureOcrWorker()
const requestId = `ocr-${Date.now()}-${++requestSequence}`
async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const response = await runOcrCommand('request', {
timeoutMs,
input: JSON.stringify({
action,
payload: payload || {},
}),
})
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('本地 OCR 返回了无效响应')
}
return response
}
async function runOcrCommand(command, { timeoutMs = DEFAULT_TIMEOUT_MS, input = '' } = {}) {
const projectRoot = resolveOcrProjectRoot()
const workerCommand = resolveWorkerCommand(projectRoot)
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
PYTHONPATH: buildPythonPath(path.join(projectRoot, 'src')),
}
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId)
worker.child.kill()
reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常'))
}, timeoutMs)
pendingRequests.set(requestId, {
resolve: (value) => {
clearTimeout(timer)
resolve(value)
},
reject: (error) => {
clearTimeout(timer)
reject(error)
},
})
worker.child.stdin.write(
`${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`,
'utf8',
)
})
}
async function ensureOcrWorker() {
if (!workerPromise) {
workerPromise = createOcrWorker().catch((error) => {
workerPromise = null
throw error
})
}
return workerPromise
}
function createOcrWorker() {
return new Promise((resolve, reject) => {
const projectRoot = resolveOcrProjectRoot()
stderrBuffer = []
const child = spawn('uv', ['run', 'ocr-worker', 'worker'], {
const child = spawn(workerCommand.command, [...workerCommand.args, command], {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
env,
})
child.stdin.setDefaultEncoding('utf8')
const stdoutReader = readline.createInterface({ input: child.stdout })
const worker = { child, stdoutReader }
let stdout = ''
let stderr = ''
let settled = false
let timedOut = false
const readyTimer = setTimeout(() => {
reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`))
const timer = setTimeout(() => {
timedOut = true
child.kill()
}, 15_000)
}, timeoutMs)
let ready = false
stdoutReader.on('line', (line) => {
const message = tryParseJson(line)
if (!message || typeof message !== 'object') {
return
}
if (message.type === 'ready') {
if (!ready) {
ready = true
clearTimeout(readyTimer)
resolve(worker)
}
return
}
if (message.type !== 'response') {
return
}
const requestId = String(message.id || '')
const pending = pendingRequests.get(requestId)
if (!pending) {
return
}
pendingRequests.delete(requestId)
pending.resolve(message.payload)
child.stdout.on('data', (chunk) => {
stdout += String(chunk || '')
})
child.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim()
if (!text) {
return
}
stderrBuffer.push(text)
if (stderrBuffer.length > 20) {
stderrBuffer = stderrBuffer.slice(-20)
}
stderr += String(chunk || '')
})
child.on('error', (error) => {
clearTimeout(readyTimer)
const workerError = buildWorkerError(error)
failAllPendingRequests(workerError)
if (!ready) {
reject(workerError)
if (settled) {
return
}
workerPromise = null
settled = true
clearTimeout(timer)
reject(buildWorkerError(error))
})
child.on('exit', (code, signal) => {
clearTimeout(readyTimer)
const workerError = new Error(
buildWorkerExitMessage({
code,
signal,
projectRoot,
stderrText: stderrBuffer.join('\n'),
}),
)
failAllPendingRequests(workerError)
if (!ready) {
reject(workerError)
child.on('close', (code, signal) => {
if (settled) {
return
}
workerPromise = null
settled = true
clearTimeout(timer)
if (timedOut) {
reject(new Error(`本地 OCR 执行超时,请检查 ${projectRoot} 的 Python 依赖是否已经准备完成`))
return
}
if (signal || code !== 0) {
reject(
new Error(
buildWorkerExitMessage({
code,
signal,
projectRoot,
stderrText: stderr.trim(),
}),
),
)
return
}
if (command === 'healthcheck') {
resolve(null)
return
}
const parsed = tryParseJson(stdout.trim())
if (!parsed || typeof parsed !== 'object') {
reject(new Error(`本地 OCR 返回无法解析的结果${stderr.trim() ? `\n${stderr.trim()}` : ''}`))
return
}
resolve(parsed)
})
if (input) {
child.stdin.end(`${input}\n`, 'utf8')
return
}
child.stdin.end()
})
}
function failAllPendingRequests(error) {
for (const [requestId, pending] of pendingRequests.entries()) {
pendingRequests.delete(requestId)
pending.reject(error)
}
}
function resolveOcrProjectRoot() {
return String(runtimeConfig.ocr.projectRoot || '').trim()
}
function resolveWorkerCommand(projectRoot) {
const venvPython = path.join(projectRoot, '.venv', 'bin', 'python')
if (fs.existsSync(venvPython)) {
return {
command: venvPython,
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
return {
command: 'python3',
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
function buildPythonPath(srcPath) {
const current = String(process.env.PYTHONPATH || '').trim()
return current ? `${srcPath}${path.delimiter}${current}` : srcPath
}
function buildWorkerError(error) {
if (error instanceof Error && error.message.includes('spawn uv ENOENT')) {
return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里')
if (
error instanceof Error &&
(error.message.includes('spawn python3 ENOENT') ||
error.message.includes('spawn ') && error.message.includes('python'))
) {
const projectRoot = resolveOcrProjectRoot()
return new Error(
`无法启动本地 OCR。请检查 Python 是否可用,并确认 OCR_PROJECT_ROOT 指向的目录存在:${projectRoot}`,
)
}
return new Error(
`本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
`本地 OCR 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
)
}
@@ -198,10 +182,10 @@ function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) {
? `exit code ${code}`
: 'unknown reason'
const installHint = `先执行: cd ${projectRoot} && uv sync`
const installHint = `检查 ${projectRoot} 的 Python 依赖是否已安装;本地源码运行请先执行 uv syncDocker 请重建 backend 镜像`
const stderrHint = stderrText ? `\n${stderrText}` : ''
return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}`
return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}`
}
function tryParseJson(text) {
+33 -3
View File
@@ -5,12 +5,31 @@ import { buildClaimUrl } from './claim-service.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { ensureAgisoClaimMessageDeliveredForTask } from './message-service.js'
import { nowIso } from '../utils/time.js'
import { logWebhook } from '../utils/logger.js'
export async function upsertOrderFromWebhook(event) {
const now = nowIso()
const existing = findOrderByPlatformOrderId(event.platform, event.platformOrderId)
const basePayload = {
const existing = findOrderByPlatformOrderId({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
platformOrderId: event.platformOrderId,
})
logWebhook('[order-service]', '开始处理 webhook 订单 upsert', {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
shopName: event.shopName,
platformOrderId: event.platformOrderId,
existingOrderId: existing?.id || null,
})
const basePayload = {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
shopName: event.shopName,
platformOrderId: event.platformOrderId,
orderStatus: event.orderStatus,
payStatus: event.payStatus,
@@ -50,8 +69,19 @@ export async function upsertOrderFromWebhook(event) {
const tasks = syncDeliveryTasksForOrder(order, orderItems)
const messageDeliveries = []
logWebhook('[order-service]', 'Webhook 订单 upsert 完成', {
orderId: order.id,
provider: order.provider,
platform: order.platform,
shopId: order.shop_id,
shopName: order.shop_name,
platformOrderId: order.platform_order_id,
orderItemCount: orderItems.length,
taskCount: tasks.length,
})
for (const task of tasks) {
if (event.platform !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) {
if (event.provider !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) {
continue
}
@@ -1,4 +1,5 @@
import { runtimeConfig } from '../config/runtime.js'
import { logDebug } from '../utils/logger.js'
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
@@ -7,12 +8,7 @@ export function logBrowserSessionDebug(scope, detail) {
return
}
if (typeof detail === 'undefined') {
console.log(`[browser/session][debug] ${scope}`)
return
}
console.log(`[browser/session][debug] ${scope}:`, detail)
logDebug('[browser/session]', scope, detail)
}
export async function waitForFrame(page, predicate, timeoutMs = 15_000) {
+387 -26
View File
@@ -4,29 +4,59 @@ import { runtimeConfig } from '../config/runtime.js'
import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js'
import { upsertOrderFromWebhook } from './order-service.js'
import { createHttpError } from '../utils/http.js'
import { logWebhook } from '../utils/logger.js'
import { nowIso } from '../utils/time.js'
export async function processAgisoTradeWebhook(requestLike) {
const parsed = parseAgisoTradeRequest(requestLike)
const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed))
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', {
requestId: requestLike.requestId || '',
webhookEventId: webhookEvent.id,
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
eventKey: parsed.eventKey,
eventType: parsed.eventType,
platformOrderId: parsed.platformOrderId,
signatureValid: parsed.signatureValid,
})
return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' })
}
export async function replayAgisoTradeWebhookEvent(webhookEvent) {
const requestLike = {
requestId: `replay-${webhookEvent.id}`,
headers: safeParseJson(webhookEvent.headers_json),
query: safeParseJson(webhookEvent.query_json),
body: safeParseJson(webhookEvent.body_json),
}
const parsed = parseAgisoTradeRequest(requestLike)
return executeAgisoTradeWebhook(parsed, webhookEvent.id)
logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', {
requestId: requestLike.requestId,
webhookEventId: webhookEvent.id,
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
eventKey: parsed.eventKey,
eventType: parsed.eventType,
platformOrderId: parsed.platformOrderId,
})
return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId })
}
function buildWebhookEventInput(requestLike, parsed) {
return {
platform: 'agiso',
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
eventType: parsed.eventType,
eventKey: parsed.eventKey,
signatureValid: parsed.signatureValid,
@@ -40,7 +70,7 @@ function buildWebhookEventInput(requestLike, parsed) {
}
}
async function executeAgisoTradeWebhook(parsed, webhookEventId) {
async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) {
try {
if (!parsed.signatureValid) {
throw createHttpError('验签失败', {
@@ -63,8 +93,28 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId) {
related_order_id: result.order.id,
})
logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', {
requestId,
webhookEventId,
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
eventKey: parsed.eventKey,
eventType: parsed.eventType,
platformOrderId: parsed.platformOrderId,
orderId: result.order.id,
taskCount: result.tasks.length,
messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0,
})
return {
accepted: true,
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
platformRaw: parsed.platformRaw,
eventType: parsed.eventType,
platformOrderId: parsed.platformOrderId,
orderId: result.order.id,
@@ -82,6 +132,25 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId) {
process_error: error instanceof Error ? error.message : String(error || ''),
related_order_id: null,
})
logWebhook(
'[webhook-service/agiso]',
'Webhook 业务处理失败',
{
requestId,
webhookEventId,
provider: parsed.provider,
platform: parsed.platform,
shopId: parsed.shopId,
shopName: parsed.shopName,
eventKey: parsed.eventKey,
eventType: parsed.eventType,
platformOrderId: parsed.platformOrderId,
signatureValid: parsed.signatureValid,
error,
},
{ level: 'error' },
)
throw error
}
}
@@ -93,34 +162,83 @@ function parseAgisoTradeRequest(requestLike) {
const payload = rawJson ? parsePayloadJson(rawJson) : body
const timestamp = String(query.timestamp || '').trim()
const sign = String(query.sign || '').trim().toLowerCase()
const eventType = resolveEventType(query.aopic)
const provider = 'agiso'
const platformRaw = pickFirstNonEmpty([
query.fromPlatform,
query.from_platform,
body.fromPlatform,
body.from_platform,
payload.fromPlatform,
payload.FromPlatform,
payload.platform,
payload.Platform,
])
const platform = resolveBusinessPlatform(platformRaw)
const shop = resolveShop(payload)
const eventType = resolveEventType(query.aopic, payload)
const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign })
const itemSources = extractOrderItemSources(payload)
const firstItem = itemSources[0] || {}
const platformOrderId = pickFirstNonEmpty([
payload.biz_order_id,
payload.Tid,
payload.tid,
payload.Oid,
payload.oid,
payload.order_id,
payload.orderId,
firstItem.Oid,
firstItem.oid,
])
return {
platform: 'agiso',
provider,
platform,
platformRaw,
eventType,
eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`,
eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'),
signatureValid,
platformOrderId,
shopId: shop.shopId,
shopName: shop.shopName,
orderStatus: resolveOrderStatus(eventType, payload),
payStatus: resolvePayStatus(eventType, payload),
buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]),
buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]),
buyerId: pickFirstNonEmpty([
payload.buyer_id,
payload.buyerId,
payload.BuyerId,
payload.openid,
payload.BuyerOpenUid,
payload.buyer_open_uid,
payload.buyerOpenUid,
]),
buyerName: pickFirstNonEmpty([
payload.buyer_name,
payload.buyerName,
payload.BuyerName,
payload.nick,
payload.BuyerNick,
payload.buyer_nick,
payload.buyerNick,
]),
receiverContact: pickFirstNonEmpty([
payload.receiver_contact,
payload.receiverContact,
payload.mobile,
payload.phone,
payload.receiver_mobile,
payload.receiverMobile,
]),
totalAmount: normalizeInteger(
pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]),
pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.Payment,
payload.payment,
]),
),
currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY',
paidAt: resolvePaidAt(eventType, payload),
@@ -159,13 +277,17 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) {
return legacyDigest === sign
}
function resolveEventType(aopic) {
function resolveEventType(aopic, payload) {
const normalized = String(aopic || '').trim()
if (normalized === '1') {
return 'payment_success'
}
if (normalized === '21') {
return 'trade_create'
}
if (normalized === '32') {
return 'trade_create'
}
@@ -178,10 +300,29 @@ function resolveEventType(aopic) {
return 'trade_closed'
}
const status = resolveProviderStatus(payload)
if (status === 'closed') {
return 'trade_closed'
}
return 'trade_event'
}
function resolveOrderStatus(eventType, payload) {
const providerStatus = resolveProviderStatus(payload)
if (providerStatus === 'paid') {
return 'paid'
}
if (providerStatus === 'closed') {
return 'closed'
}
if (providerStatus === 'refunded') {
return 'refunded'
}
if (eventType === 'payment_success') {
return 'paid'
}
@@ -210,6 +351,20 @@ function resolveOrderStatus(eventType, payload) {
}
function resolvePayStatus(eventType, payload) {
const providerStatus = resolveProviderStatus(payload)
if (providerStatus === 'paid') {
return 'paid'
}
if (providerStatus === 'closed') {
return 'unpaid'
}
if (providerStatus === 'refunded') {
return 'refunded'
}
if (eventType === 'payment_success') {
return 'paid'
}
@@ -234,6 +389,24 @@ function resolvePayStatus(eventType, payload) {
}
function resolvePaidAt(eventType, payload) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
if (resolveProviderStatus(payload) === 'paid') {
return nowIso()
}
if (eventType === 'payment_success') {
return nowIso()
}
@@ -266,13 +439,17 @@ function parsePayloadJson(rawJson) {
}
function normalizeOrderItems(payload) {
const items = Array.isArray(payload.items) && payload.items.length > 0
? payload.items
: [payload]
const items = extractOrderItemSources(payload)
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const rawSkuKey = pickFirstNonEmpty([
const rawSkuCandidates = [
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
@@ -280,13 +457,18 @@ function normalizeOrderItems(payload) {
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
])
const skuCode = resolveSkuCode(rawSkuKey)
payload.item_id,
payload.itemId,
]
const skuCode = resolveSkuCode(rawSkuCandidates)
return {
skuCode,
skuName: pickFirstNonEmpty([
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
@@ -295,25 +477,204 @@ function normalizeOrderItems(payload) {
]),
quantity: Math.max(
1,
normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
}
})
}
function resolveSkuCode(rawKey) {
const mappings = runtimeConfig.orders.skuMappings
function extractOrderItemSources(payload) {
const candidates = [
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
if (rawKey && mappings && typeof mappings === 'object') {
const direct = mappings[String(rawKey)]
if (typeof direct === 'string' && direct.trim()) {
return direct.trim()
for (const current of candidates) {
if (Array.isArray(current) && current.length > 0) {
return current
}
}
if (typeof rawKey === 'string' && rawKey.trim()) {
return rawKey.trim()
return [payload]
}
function resolveBusinessPlatform(rawPlatform) {
const normalized = normalizePlatformKey(rawPlatform)
if (!normalized) {
return 'unknown'
}
const aliases = {
xianyu: 'xianyu',
idlefish: 'xianyu',
idle: 'xianyu',
taobaoidle: 'xianyu',
taobaoxianyu: 'xianyu',
taobao: 'taobao',
tb: 'taobao',
pdd: 'pdd',
pinduoduo: 'pdd',
douyin: 'douyin',
weidian: 'weidian',
jd: 'jd',
jingdong: 'jd',
}
return aliases[normalized] || normalized
}
function normalizePlatformKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '')
}
function resolveShop(payload) {
const shopName = pickFirstNonEmpty([
payload.shop_name,
payload.shopName,
payload.ShopName,
payload.seller_name,
payload.sellerName,
payload.seller_nick,
payload.sellerNick,
payload.SellerNick,
])
return {
shopId: pickFirstNonEmpty([
payload.shop_id,
payload.shopId,
payload.ShopId,
payload.seller_id,
payload.sellerId,
payload.SellerId,
payload.PlatformUserId,
payload.platformUserId,
shopName,
]),
shopName,
}
}
function resolveProviderStatus(payload) {
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 3 || rawStatus === 4) {
return 'paid'
}
if (rawStatus === 6) {
return 'closed'
}
if (rawStatus === 5) {
return 'refunded'
}
const statusText = pickFirstNonEmpty([
payload.Status,
payload.status,
payload.order_status_text,
payload.orderStatusText,
]).toUpperCase()
if (!statusText) {
return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown'
}
if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) {
return 'paid'
}
if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) {
return 'closed'
}
if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) {
return 'refunded'
}
if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) {
return 'unpaid'
}
return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown'
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
const isoText = normalized.replace(' ', 'T')
const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/)
if (utcMatch) {
const [, year, month, day, hours, minutes, seconds] = utcMatch
return new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours) - 8,
Number(minutes),
Number(seconds),
),
).toISOString()
}
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
function resolveSkuCode(rawKey) {
const mappings = runtimeConfig.orders.skuMappings
const candidates = Array.isArray(rawKey) ? rawKey : [rawKey]
if (mappings && typeof mappings === 'object') {
for (const candidate of candidates) {
const normalizedCandidate = String(candidate || '').trim()
if (!normalizedCandidate) {
continue
}
const direct = mappings[normalizedCandidate]
if (typeof direct === 'string' && direct.trim()) {
return direct.trim()
}
}
}
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim()
}
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return String(candidate)
}
}
return ''
+27 -9
View File
@@ -1,3 +1,7 @@
import { logError, logWarn } from './logger.js'
const RESPONSE_TIME_ZONE = 'Asia/Shanghai'
export function buildSuccessPayload(data, msg = 'ok') {
return {
code: 0,
@@ -21,8 +25,12 @@ export function buildErrorPayload(error, fallbackMessage) {
export function sendRouteError(res, error, fallbackMessage, scope) {
const classification = classifyRouteError(error)
const logger = classification.statusCode >= 500 ? console.error : console.warn
logger(`${scope} ${fallbackMessage}:`, error)
const logger = classification.statusCode >= 500 ? logError : logWarn
logger(scope, fallbackMessage, {
statusCode: classification.statusCode,
errorCode: classification.errorCode,
error,
})
res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage))
}
@@ -150,12 +158,22 @@ function formatResponseDateTime(value) {
return String(value || '').replace('T', ' ').replace('Z', '')
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: RESPONSE_TIME_ZONE,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(date)
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
const tokens = Object.fromEntries(
parts
.filter((item) => item.type !== 'literal')
.map((item) => [item.type, item.value]),
)
return `${tokens.year}-${tokens.month}-${tokens.day} ${tokens.hour}:${tokens.minute}:${tokens.second}`
}
+134
View File
@@ -0,0 +1,134 @@
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js'
const DATA_ROOT = resolveDataRoot()
const LOG_DIR = path.join(DATA_ROOT, 'logs')
const APP_LOG_FILE = path.join(LOG_DIR, 'app.log')
const WEBHOOK_LOG_FILE = path.join(LOG_DIR, 'webhook.log')
let writeQueue = Promise.resolve()
export function createRequestId(prefix = 'req') {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
export function logDebug(scope, message, detail) {
return writeLog('debug', scope, message, detail)
}
export function logInfo(scope, message, detail) {
return writeLog('info', scope, message, detail)
}
export function logWarn(scope, message, detail) {
return writeLog('warn', scope, message, detail)
}
export function logError(scope, message, detail) {
return writeLog('error', scope, message, detail)
}
export function logWebhook(scope, message, detail, { level = 'info' } = {}) {
return writeLog(level, scope, message, detail, { channel: 'webhook' })
}
function writeLog(level, scope, message, detail, { channel = 'app' } = {}) {
const entry = {
time: new Date().toISOString(),
level,
scope: String(scope || 'app').trim() || 'app',
message: String(message || '').trim() || '-',
pid: process.pid,
detail: normalizeLogValue(detail),
}
writeConsole(entry)
enqueueFileWrite(entry, channel === 'webhook' ? WEBHOOK_LOG_FILE : APP_LOG_FILE)
return entry
}
function enqueueFileWrite(entry, filePath) {
const line = JSON.stringify(entry)
writeQueue = writeQueue
.then(async () => {
await fs.mkdir(LOG_DIR, { recursive: true })
await fs.appendFile(filePath, `${line}\n`, 'utf8')
})
.catch((error) => {
const reason = error instanceof Error ? error.message : String(error || '未知错误')
console.error('[logger] failed to write log file:', reason)
})
}
function writeConsole(entry) {
const prefix = `[${entry.time}] [${entry.level}] [${entry.scope}] ${entry.message}`
const logger = resolveConsoleMethod(entry.level)
if (typeof entry.detail === 'undefined') {
logger(prefix)
return
}
logger(prefix, entry.detail)
}
function resolveConsoleMethod(level) {
if (level === 'error') {
return console.error
}
if (level === 'warn') {
return console.warn
}
return console.log
}
function resolveDataRoot() {
const configuredDatabasePath = String(runtimeConfig.database?.filePath || '').trim()
if (configuredDatabasePath) {
const configuredDataRoot = path.dirname(configuredDatabasePath)
if (existsSync(configuredDataRoot)) {
return configuredDataRoot
}
}
return path.resolve(PROJECT_ROOT, 'data')
}
function normalizeLogValue(value) {
if (typeof value === 'undefined') {
return undefined
}
try {
return JSON.parse(
JSON.stringify(value, (_key, current) => {
if (current instanceof Error) {
return {
name: current.name,
message: current.message,
stack: current.stack,
statusCode: current.statusCode,
errorCode: current.errorCode,
}
}
if (typeof current === 'bigint') {
return String(current)
}
return current
}),
)
} catch {
return String(value)
}
}
+10 -3
View File
@@ -5,12 +5,19 @@
只保留最核心的能力:
- 加载 `ddddocr`
- 从标准输入接收 JSON
- 返回识别结果 JSON 行
- 接收一次 OCR 请求并返回一次 JSON 结果
- 提供一个最小化健康检查入口
初始化依赖:
```bash
cd /Users/yml/codes/order-site-backend/subservices/ocr-worker
cd /Users/yml/codes/order-site-workspace/apps/backend/subservices/ocr-worker
uv sync
```
说明:
- 本地源码调试时,先执行 `uv sync`
- Docker 镜像构建时,会把 OCR 依赖直接安装到容器内的 Python 环境
- Docker 运行时只使用容器内 `python3`
- 后端现在按次调用 OCR Python 进程,不再维护常驻 worker
@@ -13,11 +13,20 @@ from .engine import get_ocr_engine, recognize_image
def main() -> None:
parser = argparse.ArgumentParser(prog="ocr-worker", description="本地 OCR worker")
parser.add_argument("command", nargs="?", default="worker", choices=["worker"])
parser.add_argument(
"command",
nargs="?",
default="request",
choices=["worker", "request", "healthcheck"],
)
args = parser.parse_args()
if args.command == "worker":
run_worker()
elif args.command == "request":
run_single_request()
elif args.command == "healthcheck":
run_healthcheck()
def run_worker() -> None:
@@ -56,6 +65,35 @@ def run_worker() -> None:
)
def run_single_request() -> None:
get_ocr_engine()
raw_text = sys.stdin.read().strip()
if not raw_text:
raise ValueError("缺少 OCR 请求内容")
try:
request = json.loads(raw_text)
action = str(request.get("action") or "recognize").strip().lower()
payload = request.get("payload") or {}
if action == "recognize":
response_payload = build_success_payload(recognize_payload(payload), "ok")
elif action == "batch":
response_payload = build_success_payload(batch_recognize_payload(payload), "ok")
else:
response_payload = build_error_payload(f"不支持的 action: {action}")
except Exception as error: # noqa: BLE001
response_payload = build_error_payload(str(error) or "请求失败")
write_message(response_payload)
def run_healthcheck() -> None:
get_ocr_engine()
write_message({"code": 0, "msg": "ok"})
def recognize_payload(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("payload 必须是对象")
@@ -177,3 +215,7 @@ def build_timestamp() -> str:
def write_message(payload: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+28 -9
View File
@@ -88,6 +88,31 @@
"vue": "^3.2.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -95,6 +120,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -540,7 +566,6 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -551,7 +576,6 @@
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1486,15 +1510,13 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1840,7 +1862,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2019,7 +2040,6 @@
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -2104,7 +2124,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
+1 -1
View File
@@ -1,4 +1,4 @@
{$APP_DOMAIN:localhost} {
{$CADDY_SITE_ADDR:http://localhost} {
route {
handle /api/* {
reverse_proxy backend:3000
+15
View File
@@ -0,0 +1,15 @@
{$CADDY_SITE_ADDR:http://localhost} {
route {
handle /api/* {
reverse_proxy backend:3000
}
handle /health {
reverse_proxy backend:3000
}
handle {
reverse_proxy frontend:5173
}
}
}
+5 -8
View File
@@ -5,9 +5,8 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
ARG UV_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV UV_LINK_MODE=copy
ENV UV_INDEX_URL=${UV_INDEX_URL}
ENV PIP_INDEX_URL=${UV_INDEX_URL}
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
@@ -17,20 +16,18 @@ RUN sed -i "s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /e
&& sed -i "s|http://security.debian.org/debian-security|https://${DEBIAN_MIRROR}/debian-security|g" /etc/apt/sources.list.d/debian.sources
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates python3 python3-pip \
&& apt-get install -y --no-install-recommends curl ca-certificates python3 python3-pip tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m pip install --no-cache-dir --break-system-packages uv \
&& ln -sf /usr/local/bin/uv /usr/bin/uv
COPY apps/backend/package.json apps/backend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
RUN PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/ npx playwright install --with-deps chromium
COPY apps/backend/subservices/ocr-worker/ ./subservices/ocr-worker/
RUN --mount=type=cache,target=/root/.cache/uv rm -f subservices/ocr-worker/uv.lock \
&& uv sync --directory subservices/ocr-worker
RUN python3 -m pip install --no-cache-dir --break-system-packages ./subservices/ocr-worker
COPY apps/backend/ ./
+7
View File
@@ -0,0 +1,7 @@
FROM caddy:2-alpine
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone
+7
View File
@@ -2,6 +2,7 @@ FROM node:22-bookworm AS builder
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV TZ=Asia/Shanghai
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
WORKDIR /app
@@ -14,5 +15,11 @@ RUN npm run build
FROM caddy:2-alpine
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone
COPY deploy/caddy/Caddyfile /etc/caddy/Caddyfile
COPY --from=builder /app/dist /srv
+76
View File
@@ -0,0 +1,76 @@
services:
backend:
build:
context: .
dockerfile: deploy/docker/backend.Dockerfile
restart: unless-stopped
env_file:
- .env
command: >-
sh -lc
"npm install --no-fund --no-audit &&
python3 -m pip install --no-cache-dir --break-system-packages -e /app/subservices/ocr-worker &&
npm run dev"
environment:
TZ: ${TZ:-Asia/Shanghai}
PORT: ${BACKEND_PORT:-3000}
DATABASE_FILE_PATH: /app/data/order-site.db
OCR_PROJECT_ROOT: /app/subservices/ocr-worker
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-basic}
TENCENT_BROWSER_HEADLESS: ${TENCENT_BROWSER_HEADLESS:-true}
TENCENT_BROWSER_PREWARM: ${TENCENT_BROWSER_PREWARM:-true}
TENCENT_BROWSER_KEEP_ALIVE: ${TENCENT_BROWSER_KEEP_ALIVE:-true}
TENCENT_SESSION_DEBUG: ${TENCENT_SESSION_DEBUG:-false}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
ORDER_SKU_MAPPINGS_JSON: ${ORDER_SKU_MAPPINGS_JSON}
AGISO_APP_SECRET: ${AGISO_APP_SECRET:-}
AGISO_MESSAGING_ENABLED: ${AGISO_MESSAGING_ENABLED:-false}
AGISO_APP_ID: ${AGISO_APP_ID:-}
AGISO_ACCESS_TOKEN: ${AGISO_ACCESS_TOKEN:-}
AGISO_MESSAGE_APP_SECRET: ${AGISO_MESSAGE_APP_SECRET:-}
AGISO_MESSAGE_TEMPLATE: ${AGISO_MESSAGE_TEMPLATE:-}
volumes:
- ./apps/backend:/app
- backend_node_modules:/app/node_modules
- ./apps/backend/data:/app/data
frontend:
image: node:22-bookworm
working_dir: /app
restart: unless-stopped
command: >-
sh -lc
"npm install --no-fund --no-audit &&
npm run dev -- --host 0.0.0.0 --port 5173"
environment:
TZ: ${TZ:-Asia/Shanghai}
CHOKIDAR_USEPOLLING: "true"
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
VITE_API_TARGET: http://backend:3000
volumes:
- ./apps/frontend:/app
- frontend_node_modules:/app/node_modules
web:
build:
context: .
dockerfile: deploy/docker/dev-caddy.Dockerfile
restart: unless-stopped
depends_on:
- backend
- frontend
ports:
- "80:80"
- "443:443"
environment:
TZ: ${TZ:-Asia/Shanghai}
APP_DOMAIN: ${APP_DOMAIN:-localhost}
CADDY_SITE_ADDR: ${CADDY_SITE_ADDR:-http://localhost}
volumes:
- ./deploy/caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro
volumes:
backend_node_modules:
frontend_node_modules:
+5 -2
View File
@@ -7,9 +7,10 @@ services:
env_file:
- .env
environment:
TZ: ${TZ:-Asia/Shanghai}
PORT: ${BACKEND_PORT:-3000}
DATABASE_FILE_PATH: ${DATABASE_FILE_PATH:-/app/data/order-site.db}
OCR_PROJECT_ROOT: ${OCR_PROJECT_ROOT:-/app/subservices/ocr-worker}
DATABASE_FILE_PATH: /app/data/order-site.db
OCR_PROJECT_ROOT: /app/subservices/ocr-worker
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
TENCENT_REDEEM_PROOF_MODE: ${TENCENT_REDEEM_PROOF_MODE:-basic}
TENCENT_BROWSER_HEADLESS: ${TENCENT_BROWSER_HEADLESS:-true}
@@ -41,7 +42,9 @@ services:
- "80:80"
- "443:443"
environment:
TZ: ${TZ:-Asia/Shanghai}
APP_DOMAIN: ${APP_DOMAIN:-localhost}
CADDY_SITE_ADDR: ${CADDY_SITE_ADDR:-http://localhost}
volumes:
- caddy_data:/data
- caddy_config:/config
@@ -9,6 +9,11 @@ TbAlds54ez66ateztecyprxtn6kb98w23ghy6r9c5yxt2zueknd
可将AccessToken复制给应用商!
咸鱼的
AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4
拼接用户授权需访问url ,示例及参数说明如下:
@@ -51,3 +56,10 @@ ngrok
https://5cc8-193-176-84-38.ngrok-free.app
https://5cc8-193-176-84-38.ngrok-free.app/api/v1/webhooks/agiso/trade
----
服务器 https://order.khhao.com/api/v1/webhooks/agiso/trade
调试 https://926d-193-176-84-20.ngrok-free.app/api/v1/webhooks/agiso/trade
-191
View File
@@ -1,191 +0,0 @@
# 服务器部署教程
## 目标
`order-site-workspace``Docker Compose + Caddy` 方式部署到 Linux 服务器。
## 一、服务器准备
建议服务器最低配置:
- 2 vCPU
- 4 GB 内存
- 30 GB 可用磁盘
系统建议:
- Ubuntu 22.04 / Debian 12
需要提前准备:
1. 一个已经解析到服务器公网 IP 的域名
2. 服务器开放端口:
- `80`
- `443`
3. 已安装 Docker 和 Docker Compose
## 二、上传项目
将整个工作区上传到服务器,例如:
```bash
cd /srv
git clone <your-repo-url> order-site-workspace
cd order-site-workspace
```
如果不是 Git 部署,也可以直接上传整个目录。
## 三、填写生产配置
复制环境变量模板:
```bash
cp .env.example .env
```
然后重点修改:
1. `APP_DOMAIN`
改成你的真实域名,例如 `order.example.com`
2. `APP_BASE_URL`
改成 `https://你的域名`
3. `CLAIM_BASE_URL`
改成 `https://你的域名/#/claim`
4. `ADMIN_SESSION_SECRET`
换成高强度随机串
5. `ADMIN_DEFAULT_USERS_JSON`
换成正式后台账号密码
6. `ORDER_SKU_MAPPINGS_JSON`
改成你的真实商品映射
7. `AGISO_*`
写入真实平台密钥
参考文件:
- [生产环境变量模板.env](/Users/yml/codes/order-site-workspace/docs/%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E6%A8%A1%E6%9D%BF.env)
## 四、首次构建与启动
在服务器执行:
```bash
docker compose build
docker compose up -d
```
如果服务器在国内,当前版本默认已启用国内镜像源加速:
- Debian `apt`:腾讯云镜像
- `npm``npmmirror`
- `uv` 通过 `pip` 安装,`uv` / `pip` 使用腾讯云 PyPI 镜像
- 后端 Playwright 固定为 `1.42.1`,浏览器下载使用 `npmmirror`
如果你要切换成别的源,可以手动执行:
```bash
docker compose build \
--build-arg DEBIAN_MIRROR=mirrors.tuna.tsinghua.edu.cn \
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
--build-arg UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
docker compose up -d
```
查看运行状态:
```bash
docker compose ps
docker compose logs -f
```
## 五、上线后检查
先检查健康接口:
```bash
curl -k https://你的域名/health
```
预期返回:
```json
{"code":0,"msg":"ok","time":1234567890}
```
再检查前端页面:
1. 打开 `https://你的域名`
2. 打开后台登录页 `https://你的域名/#/admin/login`
3. 使用 `.env` 中的管理员账号登录
## 六、常用运维命令
启动:
```bash
docker compose up -d
```
停止:
```bash
docker compose down
```
重建:
```bash
docker compose build
docker compose up -d
```
查看日志:
```bash
docker compose logs -f backend
docker compose logs -f web
```
## 七、升级流程
更新代码后执行:
```bash
git pull
docker compose build
docker compose up -d
```
如果只更新前端:
```bash
docker compose build web
docker compose up -d web
```
如果只更新后端:
```bash
docker compose build backend
docker compose up -d backend
```
## 八、数据位置与备份
当前后端数据通过 Docker 卷持久化,至少包含:
- SQLite 数据库
- 浏览器会话产物
- 兑换截图/证明文件
建议至少每日备份一次:
1. `backend_data`
2. `.env`
## 九、当前版本的注意事项
1. 当前 HTTPS 由 Caddy 自动处理,域名必须先解析到服务器
2. 领取页地址依赖 `CLAIM_BASE_URL`,域名变更后必须同步修改
3. 后端使用 SQLite,单机部署没有问题,但后期并发增大时建议切换外部数据库
4. Playwright 和 OCR 已在镜像中内置,首次构建时间较长,后续会快很多
-24
View File
@@ -1,24 +0,0 @@
APP_DOMAIN=order.example.com
APP_BASE_URL=https://order.example.com
BACKEND_PORT=3000
CLAIM_BASE_URL=https://order.example.com/#/claim
DATABASE_FILE_PATH=/app/data/order-site.db
OCR_PROJECT_ROOT=/app/subservices/ocr-worker
TENCENT_REDEEM_PROOF_MODE=basic
TENCENT_BROWSER_HEADLESS=true
TENCENT_BROWSER_PREWARM=true
TENCENT_BROWSER_KEEP_ALIVE=true
TENCENT_SESSION_DEBUG=false
ADMIN_SESSION_SECRET=replace-with-a-long-random-secret
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"replace-with-strong-admin-password","role":"admin"},{"username":"operator","password":"replace-with-strong-operator-password","role":"operator"}]
ORDER_SKU_MAPPINGS_JSON={"32768":"dnf-cdk-a","dnf-cdk-a":"dnf-cdk-a"}
AGISO_APP_SECRET=
AGISO_MESSAGING_ENABLED=true
AGISO_APP_ID=
AGISO_ACCESS_TOKEN=
AGISO_MESSAGE_APP_SECRET=
AGISO_MESSAGE_TEMPLATE=您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl}
-66
View File
@@ -1,66 +0,0 @@
# 迁移与部署计划
## 目标
将现有两个项目整理为统一工作区:
- `apps/backend`
- `apps/frontend`
并以 `Docker Compose + Caddy` 作为标准部署方式。
## 已完成
1. 新建统一工作区目录 `order-site-workspace`
2. 将原后端迁移到 `apps/backend`
3. 将原前端迁移到 `apps/frontend`
4. 增加 `docker-compose.yml`
5. 增加后端 Dockerfile
6. 增加前端+Caddy Dockerfile
7. 增加 Caddy 反代配置
8. 增加根目录 `.env.example`
9. 后端新增环境变量支持:
- `ADMIN_DEFAULT_USERS_JSON`
- `ORDER_SKU_MAPPINGS_JSON`
- `AGISO_MESSAGE_TEMPLATE`
## 当前部署结构
### backend
- Node 运行主服务
- Playwright 安装 Chromium
- `uv` 管理 OCR 子服务
- SQLite 与截图产物通过 Docker 卷持久化
### web
- 前端先构建
- 最终由 Caddy 提供静态文件
- `/api/*``/health` 反向代理到 backend
## 上线前仍需确认
1. `.env` 中的生产域名
2. 后台默认账号密码
3. Agiso 密钥与消息配置
4. SKU 映射 JSON
5. 是否继续使用 SQLite,或后续切换 MySQL/PostgreSQL
## 建议的下一步
1. 在新工作区本地执行一次:
- `docker compose build`
- `docker compose up -d`
2. 验证:
- `/health`
- `/api/v1/admin/auth/login`
- 前台 claim 页面
- 后台管理页
3. 再补服务器部署文档与备份策略
## 已补充文档
- [服务器部署教程](/Users/yml/codes/order-site-workspace/docs/%E6%9C%8D%E5%8A%A1%E5%99%A8%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B.md)
- [首次上线检查清单](/Users/yml/codes/order-site-workspace/docs/%E9%A6%96%E6%AC%A1%E4%B8%8A%E7%BA%BF%E6%A3%80%E6%9F%A5%E6%B8%85%E5%8D%95.md)
- [生产环境变量模板.env](/Users/yml/codes/order-site-workspace/docs/%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E6%A8%A1%E6%9D%BF.env)
-57
View File
@@ -1,57 +0,0 @@
# 首次上线检查清单
## 基础检查
1. 域名已正确解析到服务器公网 IP
2. 服务器已开放 `80/443`
3. `.env` 已替换为真实生产配置
4. 后台默认密码不是示例密码
5. Agiso 密钥不是空值
## 容器检查
1. `docker compose ps` 显示 `backend``web` 都是 `Up`
2. `docker compose logs --tail=100 backend` 没有持续报错
3. `docker compose logs --tail=100 web` 没有持续报错
## 接口检查
1. `https://你的域名/health` 返回 `code=0`
2. `https://你的域名/` 能打开前台首页
3. `https://你的域名/#/admin/login` 能打开后台登录页
## 后台检查
1. 管理员账号可登录
2. 订单页可打开
3. 任务页可打开
4. CDK 页可打开
5. Webhook 页可打开
## 业务检查
1. SKU 映射正确
2. 可手动新增 CDK
3. 可创建测试订单并生成任务
4. 领取链接能正常打开
5. 用户绑定流程能走通
6. 兑换成功后截图/结果文件能生成
## 回调检查
1. Agiso webhook 地址已配置为真实公网地址
2. 订单创建通知可以写入系统
3. 付款成功通知可以推进任务
4. 确认收货通知可以完成订单状态
## 消息检查
1. Agiso 站内消息开关已确认
2. 消息模板内容符合当前业务
3. 领取链接能通过消息正确发给用户
## 备份检查
1. `.env` 已单独安全保存
2. `backend_data` 卷已有备份方案
3. 数据库文件可导出