开始多平台 多店铺整改
This commit is contained in:
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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`:完整证明
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
try {
|
||||
const data = await processAgisoTradeWebhook({
|
||||
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]')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+120
-136
@@ -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(
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
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: stderrBuffer.join('\n'),
|
||||
stderrText: stderr.trim(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
failAllPendingRequests(workerError)
|
||||
if (!ready) {
|
||||
reject(workerError)
|
||||
return
|
||||
}
|
||||
workerPromise = null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function failAllPendingRequests(error) {
|
||||
for (const [requestId, pending] of pendingRequests.entries()) {
|
||||
pendingRequests.delete(requestId)
|
||||
pending.reject(error)
|
||||
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 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 sync,Docker 请重建 backend 镜像`
|
||||
const stderrHint = stderrText ? `\n${stderrText}` : ''
|
||||
|
||||
return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}`
|
||||
return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}`
|
||||
}
|
||||
|
||||
function tryParseJson(text) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 extractOrderItemSources(payload) {
|
||||
const candidates = [
|
||||
payload.items,
|
||||
payload.Items,
|
||||
payload.orders,
|
||||
payload.Orders,
|
||||
payload.order_list,
|
||||
payload.OrderList,
|
||||
]
|
||||
|
||||
for (const current of candidates) {
|
||||
if (Array.isArray(current) && current.length > 0) {
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
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 (rawKey && mappings && typeof mappings === 'object') {
|
||||
const direct = mappings[String(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawKey === 'string' && rawKey.trim()) {
|
||||
return rawKey.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 ''
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Generated
+28
-9
@@ -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,4 +1,4 @@
|
||||
{$APP_DOMAIN:localhost} {
|
||||
{$CADDY_SITE_ADDR:http://localhost} {
|
||||
route {
|
||||
handle /api/* {
|
||||
reverse_proxy backend:3000
|
||||
|
||||
@@ -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,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/ ./
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 已在镜像中内置,首次构建时间较长,后续会快很多
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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. 数据库文件可导出
|
||||
Reference in New Issue
Block a user