From a2b09c89e8a02133349cdf1f848302148de80a03 Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 8 Apr 2026 22:21:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E5=A4=9A=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=20=E5=A4=9A=E5=BA=97=E9=93=BA=E6=95=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development.example | 29 ++ .env.example => .env.production.example | 3 + .gitignore | 2 +- README.md | 47 +- apps/backend/.gitignore | 3 +- apps/backend/README.md | 47 +- apps/backend/config/local.example.cjs | 85 ---- apps/backend/package.json | 3 +- apps/backend/scripts/cleanup-dev-data.js | 460 ++++++++++++++++++ apps/backend/scripts/test-order-flow.sh | 3 +- apps/backend/src/config/runtime.js | 63 ++- .../005_provider_shop_dimensions.sql | 94 ++++ apps/backend/src/index.js | 27 +- .../src/repositories/message-delivery-repo.js | 8 +- apps/backend/src/repositories/order-repo.js | 22 +- .../src/repositories/webhook-event-repo.js | 14 +- apps/backend/src/routes/webhooks.js | 32 ++ apps/backend/src/services/admin-service.js | 22 +- apps/backend/src/services/message-service.js | 5 +- apps/backend/src/services/ocr.js | 268 +++++----- apps/backend/src/services/order-service.js | 36 +- .../src/services/session-login-shared.js | 8 +- apps/backend/src/services/webhook-service.js | 413 +++++++++++++++- apps/backend/src/utils/http.js | 36 +- apps/backend/src/utils/logger.js | 134 +++++ apps/backend/subservices/ocr-worker/README.md | 13 +- .../ocr-worker/src/ocr_worker/cli.py | 44 +- apps/frontend/package-lock.json | 37 +- deploy/caddy/Caddyfile | 2 +- deploy/caddy/Caddyfile.dev | 15 + deploy/docker/backend.Dockerfile | 13 +- deploy/docker/dev-caddy.Dockerfile | 7 + deploy/docker/frontend.Dockerfile | 7 + docker-compose.dev.yml | 76 +++ docker-compose.yml | 7 +- {apps/backend => docs}/app信息.md | 12 + docs/服务器部署教程.md | 191 -------- docs/生产环境变量模板.env | 24 - docs/迁移与部署计划.md | 66 --- docs/首次上线检查清单.md | 57 --- 40 files changed, 1750 insertions(+), 685 deletions(-) create mode 100644 .env.development.example rename .env.example => .env.production.example (92%) delete mode 100644 apps/backend/config/local.example.cjs create mode 100644 apps/backend/scripts/cleanup-dev-data.js create mode 100644 apps/backend/src/db/migrations/005_provider_shop_dimensions.sql create mode 100644 apps/backend/src/utils/logger.js create mode 100644 deploy/caddy/Caddyfile.dev create mode 100644 deploy/docker/dev-caddy.Dockerfile create mode 100644 docker-compose.dev.yml rename {apps/backend => docs}/app信息.md (86%) delete mode 100644 docs/服务器部署教程.md delete mode 100644 docs/生产环境变量模板.env delete mode 100644 docs/迁移与部署计划.md delete mode 100644 docs/首次上线检查清单.md diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 00000000..ae76741a --- /dev/null +++ b/.env.development.example @@ -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} diff --git a/.env.example b/.env.production.example similarity index 92% rename from .env.example rename to .env.production.example index 2757e57d..6c096e9a 100644 --- a/.env.example +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore index a80a2355..dd407ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 3afee06c..8c03cbb3 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 0766591c..778e486a 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -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 diff --git a/apps/backend/README.md b/apps/backend/README.md index 32544525..01ecba6d 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -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`:完整证明 diff --git a/apps/backend/config/local.example.cjs b/apps/backend/config/local.example.cjs deleted file mode 100644 index b93a5d13..00000000 --- a/apps/backend/config/local.example.cjs +++ /dev/null @@ -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', - }, -} diff --git a/apps/backend/package.json b/apps/backend/package.json index f8d6b72d..1d78cc20 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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": { diff --git a/apps/backend/scripts/cleanup-dev-data.js b/apps/backend/scripts/cleanup-dev-data.js new file mode 100644 index 00000000..9b1bb53a --- /dev/null +++ b/apps/backend/scripts/cleanup-dev-data.js @@ -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 ` 手动指定。') + 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 按平台订单号删除,可重复传入 + --shop-id 按店铺 ID 删除,可重复传入 + --shop-name 按店铺名删除,可重复传入 + --order-id 按内部订单 ID 删除,可重复传入 + --provider 限定 provider,例如 agiso + --platform 限定业务平台,例如 xianyu + --db-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 + `) +} diff --git a/apps/backend/scripts/test-order-flow.sh b/apps/backend/scripts/test-order-flow.sh index 682606f9..0924019a 100755 --- a/apps/backend/scripts/test-order-flow.sh +++ b/apps/backend/scripts/test-order-flow.sh @@ -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" diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 9e7021e9..55395065 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -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, {}) diff --git a/apps/backend/src/db/migrations/005_provider_shop_dimensions.sql b/apps/backend/src/db/migrations/005_provider_shop_dimensions.sql new file mode 100644 index 00000000..a2c9e95a --- /dev/null +++ b/apps/backend/src/db/migrations/005_provider_shop_dimensions.sql @@ -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; diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index 84b297d2..caa66214 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -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 } diff --git a/apps/backend/src/repositories/message-delivery-repo.js b/apps/backend/src/repositories/message-delivery-repo.js index 9ca6b589..fc34af84 100644 --- a/apps/backend/src/repositories/message-delivery-repo.js +++ b/apps/backend/src/repositories/message-delivery-repo.js @@ -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, diff --git a/apps/backend/src/repositories/order-repo.js b/apps/backend/src/repositories/order-repo.js index e8e071b2..dca208a0 100644 --- a/apps/backend/src/repositories/order-repo.js +++ b/apps/backend/src/repositories/order-repo.js @@ -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, diff --git a/apps/backend/src/repositories/webhook-event-repo.js b/apps/backend/src/repositories/webhook-event-repo.js index b4258af8..28c3fc1b 100644 --- a/apps/backend/src/repositories/webhook-event-repo.js +++ b/apps/backend/src/repositories/webhook-event-repo.js @@ -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) diff --git a/apps/backend/src/routes/webhooks.js b/apps/backend/src/routes/webhooks.js index 40ff688c..820f4447 100644 --- a/apps/backend/src/routes/webhooks.js +++ b/apps/backend/src/routes/webhooks.js @@ -1,19 +1,51 @@ import { Router } from 'express' import { processAgisoTradeWebhook } from '../services/webhook-service.js' +import { createRequestId, logWebhook } from '../utils/logger.js' import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' const router = Router() router.post('/agiso/trade', async (req, res) => { + const requestId = createRequestId('wh') + const startedAt = Date.now() + + logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', { + requestId, + method: req.method, + originalUrl: req.originalUrl, + ip: req.ip, + headers: req.headers, + query: req.query, + body: req.body, + }) + try { const data = await processAgisoTradeWebhook({ + requestId, headers: req.headers, query: req.query, body: req.body, }) + + logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', { + requestId, + durationMs: Date.now() - startedAt, + result: data, + }) + res.json(buildSuccessPayload(data, 'success')) } catch (error) { + logWebhook( + '[webhooks/agiso/trade]', + 'Agiso webhook 处理失败', + { + requestId, + durationMs: Date.now() - startedAt, + error, + }, + { level: 'error' }, + ) sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]') } }) diff --git a/apps/backend/src/services/admin-service.js b/apps/backend/src/services/admin-service.js index 3e7e3e66..adbb046b 100644 --- a/apps/backend/src/services/admin-service.js +++ b/apps/backend/src/services/admin-service.js @@ -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, diff --git a/apps/backend/src/services/message-service.js b/apps/backend/src/services/message-service.js index 5b5c1e6c..6303060f 100644 --- a/apps/backend/src/services/message-service.js +++ b/apps/backend/src/services/message-service.js @@ -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, diff --git a/apps/backend/src/services/ocr.js b/apps/backend/src/services/ocr.js index 89dd8f37..b31584d2 100644 --- a/apps/backend/src/services/ocr.js +++ b/apps/backend/src/services/ocr.js @@ -1,193 +1,177 @@ import { spawn } from 'node:child_process' -import readline from 'node:readline' +import fs from 'node:fs' +import path from 'node:path' import process from 'node:process' import { runtimeConfig } from '../config/runtime.js' const DEFAULT_TIMEOUT_MS = 60_000 -let workerPromise = null -let requestSequence = 0 -const pendingRequests = new Map() -let stderrBuffer = [] - export async function recognizeTencentCaptcha(payload) { - return callOcrWorker('recognize', payload) + return callOcrRequest('recognize', payload) } export async function batchRecognizeTencentCaptcha(payload) { - return callOcrWorker('batch', payload, { timeoutMs: 180_000 }) + return callOcrRequest('batch', payload, { timeoutMs: 180_000 }) } export async function warmupLocalOcrWorker() { - await ensureOcrWorker() + await runOcrCommand('healthcheck', { timeoutMs: 30_000 }) } export async function closeLocalOcrWorker() { - const worker = await workerPromise?.catch(() => null) - - if (!worker) { - workerPromise = null - return - } - - worker.child.kill() - workerPromise = null + // OCR 改为按次调用 Python 子进程,这里不再维护常驻 worker。 } -async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { - const worker = await ensureOcrWorker() - const requestId = `ocr-${Date.now()}-${++requestSequence}` +async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const response = await runOcrCommand('request', { + timeoutMs, + input: JSON.stringify({ + action, + payload: payload || {}, + }), + }) + + if (!response || typeof response !== 'object' || Array.isArray(response)) { + throw new Error('本地 OCR 返回了无效响应') + } + + return response +} + +async function runOcrCommand(command, { timeoutMs = DEFAULT_TIMEOUT_MS, input = '' } = {}) { + const projectRoot = resolveOcrProjectRoot() + const workerCommand = resolveWorkerCommand(projectRoot) + const env = { + ...process.env, + PYTHONUNBUFFERED: '1', + PYTHONPATH: buildPythonPath(path.join(projectRoot, 'src')), + } return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingRequests.delete(requestId) - worker.child.kill() - reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常')) - }, timeoutMs) - - pendingRequests.set(requestId, { - resolve: (value) => { - clearTimeout(timer) - resolve(value) - }, - reject: (error) => { - clearTimeout(timer) - reject(error) - }, - }) - - worker.child.stdin.write( - `${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`, - 'utf8', - ) - }) -} - -async function ensureOcrWorker() { - if (!workerPromise) { - workerPromise = createOcrWorker().catch((error) => { - workerPromise = null - throw error - }) - } - - return workerPromise -} - -function createOcrWorker() { - return new Promise((resolve, reject) => { - const projectRoot = resolveOcrProjectRoot() - stderrBuffer = [] - - const child = spawn('uv', ['run', 'ocr-worker', 'worker'], { + const child = spawn(workerCommand.command, [...workerCommand.args, command], { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], - env: process.env, + env, }) - child.stdin.setDefaultEncoding('utf8') - const stdoutReader = readline.createInterface({ input: child.stdout }) - const worker = { child, stdoutReader } + let stdout = '' + let stderr = '' + let settled = false + let timedOut = false - const readyTimer = setTimeout(() => { - reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`)) + const timer = setTimeout(() => { + timedOut = true child.kill() - }, 15_000) + }, timeoutMs) - let ready = false - - stdoutReader.on('line', (line) => { - const message = tryParseJson(line) - - if (!message || typeof message !== 'object') { - return - } - - if (message.type === 'ready') { - if (!ready) { - ready = true - clearTimeout(readyTimer) - resolve(worker) - } - return - } - - if (message.type !== 'response') { - return - } - - const requestId = String(message.id || '') - const pending = pendingRequests.get(requestId) - - if (!pending) { - return - } - - pendingRequests.delete(requestId) - pending.resolve(message.payload) + child.stdout.on('data', (chunk) => { + stdout += String(chunk || '') }) child.stderr.on('data', (chunk) => { - const text = String(chunk || '').trim() - - if (!text) { - return - } - - stderrBuffer.push(text) - if (stderrBuffer.length > 20) { - stderrBuffer = stderrBuffer.slice(-20) - } + stderr += String(chunk || '') }) child.on('error', (error) => { - clearTimeout(readyTimer) - const workerError = buildWorkerError(error) - failAllPendingRequests(workerError) - if (!ready) { - reject(workerError) + if (settled) { + return } - workerPromise = null + + settled = true + clearTimeout(timer) + reject(buildWorkerError(error)) }) - child.on('exit', (code, signal) => { - clearTimeout(readyTimer) - const workerError = new Error( - buildWorkerExitMessage({ - code, - signal, - projectRoot, - stderrText: stderrBuffer.join('\n'), - }), - ) - failAllPendingRequests(workerError) - if (!ready) { - reject(workerError) + child.on('close', (code, signal) => { + if (settled) { + return } - workerPromise = null + + settled = true + clearTimeout(timer) + + if (timedOut) { + reject(new Error(`本地 OCR 执行超时,请检查 ${projectRoot} 的 Python 依赖是否已经准备完成`)) + return + } + + if (signal || code !== 0) { + reject( + new Error( + buildWorkerExitMessage({ + code, + signal, + projectRoot, + stderrText: stderr.trim(), + }), + ), + ) + return + } + + if (command === 'healthcheck') { + resolve(null) + return + } + + const parsed = tryParseJson(stdout.trim()) + if (!parsed || typeof parsed !== 'object') { + reject(new Error(`本地 OCR 返回无法解析的结果${stderr.trim() ? `\n${stderr.trim()}` : ''}`)) + return + } + + resolve(parsed) }) + + if (input) { + child.stdin.end(`${input}\n`, 'utf8') + return + } + + child.stdin.end() }) } -function failAllPendingRequests(error) { - for (const [requestId, pending] of pendingRequests.entries()) { - pendingRequests.delete(requestId) - pending.reject(error) - } -} - function resolveOcrProjectRoot() { return String(runtimeConfig.ocr.projectRoot || '').trim() } +function resolveWorkerCommand(projectRoot) { + const venvPython = path.join(projectRoot, '.venv', 'bin', 'python') + + if (fs.existsSync(venvPython)) { + return { + command: venvPython, + args: ['-u', '-m', 'ocr_worker.cli'], + } + } + + return { + command: 'python3', + args: ['-u', '-m', 'ocr_worker.cli'], + } +} + +function buildPythonPath(srcPath) { + const current = String(process.env.PYTHONPATH || '').trim() + return current ? `${srcPath}${path.delimiter}${current}` : srcPath +} + function buildWorkerError(error) { - if (error instanceof Error && error.message.includes('spawn uv ENOENT')) { - return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里') + if ( + error instanceof Error && + (error.message.includes('spawn python3 ENOENT') || + error.message.includes('spawn ') && error.message.includes('python')) + ) { + const projectRoot = resolveOcrProjectRoot() + return new Error( + `无法启动本地 OCR。请检查 Python 是否可用,并确认 OCR_PROJECT_ROOT 指向的目录存在:${projectRoot}`, + ) } return new Error( - `本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`, + `本地 OCR 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`, ) } @@ -198,10 +182,10 @@ function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) { ? `exit code ${code}` : 'unknown reason' - const installHint = `请先执行: cd ${projectRoot} && uv sync` + const installHint = `请检查 ${projectRoot} 的 Python 依赖是否已安装;本地源码运行请先执行 uv sync,Docker 请重建 backend 镜像` const stderrHint = stderrText ? `\n${stderrText}` : '' - return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}` + return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}` } function tryParseJson(text) { diff --git a/apps/backend/src/services/order-service.js b/apps/backend/src/services/order-service.js index 211592ed..9654980b 100644 --- a/apps/backend/src/services/order-service.js +++ b/apps/backend/src/services/order-service.js @@ -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 } diff --git a/apps/backend/src/services/session-login-shared.js b/apps/backend/src/services/session-login-shared.js index fa819bc8..43ca7706 100644 --- a/apps/backend/src/services/session-login-shared.js +++ b/apps/backend/src/services/session-login-shared.js @@ -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) { diff --git a/apps/backend/src/services/webhook-service.js b/apps/backend/src/services/webhook-service.js index 3ca90eb1..b5e74a01 100644 --- a/apps/backend/src/services/webhook-service.js +++ b/apps/backend/src/services/webhook-service.js @@ -4,29 +4,59 @@ import { runtimeConfig } from '../config/runtime.js' import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js' import { upsertOrderFromWebhook } from './order-service.js' import { createHttpError } from '../utils/http.js' +import { logWebhook } from '../utils/logger.js' import { nowIso } from '../utils/time.js' export async function processAgisoTradeWebhook(requestLike) { const parsed = parseAgisoTradeRequest(requestLike) const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) - return executeAgisoTradeWebhook(parsed, webhookEvent.id) + logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', { + requestId: requestLike.requestId || '', + webhookEventId: webhookEvent.id, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + eventKey: parsed.eventKey, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + signatureValid: parsed.signatureValid, + }) + + return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' }) } export async function replayAgisoTradeWebhookEvent(webhookEvent) { const requestLike = { + requestId: `replay-${webhookEvent.id}`, headers: safeParseJson(webhookEvent.headers_json), query: safeParseJson(webhookEvent.query_json), body: safeParseJson(webhookEvent.body_json), } const parsed = parseAgisoTradeRequest(requestLike) - return executeAgisoTradeWebhook(parsed, webhookEvent.id) + logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', { + requestId: requestLike.requestId, + webhookEventId: webhookEvent.id, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + eventKey: parsed.eventKey, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + }) + + return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) } function buildWebhookEventInput(requestLike, parsed) { return { - platform: 'agiso', + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, eventType: parsed.eventType, eventKey: parsed.eventKey, signatureValid: parsed.signatureValid, @@ -40,7 +70,7 @@ function buildWebhookEventInput(requestLike, parsed) { } } -async function executeAgisoTradeWebhook(parsed, webhookEventId) { +async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { try { if (!parsed.signatureValid) { throw createHttpError('验签失败', { @@ -63,8 +93,28 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId) { related_order_id: result.order.id, }) + logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', { + requestId, + webhookEventId, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + eventKey: parsed.eventKey, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + orderId: result.order.id, + taskCount: result.tasks.length, + messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, + }) + return { accepted: true, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + platformRaw: parsed.platformRaw, eventType: parsed.eventType, platformOrderId: parsed.platformOrderId, orderId: result.order.id, @@ -82,6 +132,25 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId) { process_error: error instanceof Error ? error.message : String(error || ''), related_order_id: null, }) + + logWebhook( + '[webhook-service/agiso]', + 'Webhook 业务处理失败', + { + requestId, + webhookEventId, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + eventKey: parsed.eventKey, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + signatureValid: parsed.signatureValid, + error, + }, + { level: 'error' }, + ) throw error } } @@ -93,34 +162,83 @@ function parseAgisoTradeRequest(requestLike) { const payload = rawJson ? parsePayloadJson(rawJson) : body const timestamp = String(query.timestamp || '').trim() const sign = String(query.sign || '').trim().toLowerCase() - const eventType = resolveEventType(query.aopic) + const provider = 'agiso' + const platformRaw = pickFirstNonEmpty([ + query.fromPlatform, + query.from_platform, + body.fromPlatform, + body.from_platform, + payload.fromPlatform, + payload.FromPlatform, + payload.platform, + payload.Platform, + ]) + const platform = resolveBusinessPlatform(platformRaw) + const shop = resolveShop(payload) + const eventType = resolveEventType(query.aopic, payload) const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) + const itemSources = extractOrderItemSources(payload) + const firstItem = itemSources[0] || {} const platformOrderId = pickFirstNonEmpty([ payload.biz_order_id, payload.Tid, payload.tid, + payload.Oid, + payload.oid, payload.order_id, payload.orderId, + firstItem.Oid, + firstItem.oid, ]) return { - platform: 'agiso', + provider, + platform, + platformRaw, eventType, - eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`, + eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'), signatureValid, platformOrderId, + shopId: shop.shopId, + shopName: shop.shopName, orderStatus: resolveOrderStatus(eventType, payload), payStatus: resolvePayStatus(eventType, payload), - buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]), - buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]), + buyerId: pickFirstNonEmpty([ + payload.buyer_id, + payload.buyerId, + payload.BuyerId, + payload.openid, + payload.BuyerOpenUid, + payload.buyer_open_uid, + payload.buyerOpenUid, + ]), + buyerName: pickFirstNonEmpty([ + payload.buyer_name, + payload.buyerName, + payload.BuyerName, + payload.nick, + payload.BuyerNick, + payload.buyer_nick, + payload.buyerNick, + ]), receiverContact: pickFirstNonEmpty([ payload.receiver_contact, payload.receiverContact, payload.mobile, payload.phone, + payload.receiver_mobile, + payload.receiverMobile, ]), totalAmount: normalizeInteger( - pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]), + pickFirstNonEmpty([ + payload.total_fee, + payload.totalFee, + payload.TotalFee, + payload.pay_fee, + payload.payFee, + payload.Payment, + payload.payment, + ]), ), currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', paidAt: resolvePaidAt(eventType, payload), @@ -159,13 +277,17 @@ function verifyAgisoSignature({ rawJson, timestamp, sign }) { return legacyDigest === sign } -function resolveEventType(aopic) { +function resolveEventType(aopic, payload) { const normalized = String(aopic || '').trim() if (normalized === '1') { return 'payment_success' } + if (normalized === '21') { + return 'trade_create' + } + if (normalized === '32') { return 'trade_create' } @@ -178,10 +300,29 @@ function resolveEventType(aopic) { return 'trade_closed' } + const status = resolveProviderStatus(payload) + if (status === 'closed') { + return 'trade_closed' + } + return 'trade_event' } function resolveOrderStatus(eventType, payload) { + const providerStatus = resolveProviderStatus(payload) + + if (providerStatus === 'paid') { + return 'paid' + } + + if (providerStatus === 'closed') { + return 'closed' + } + + if (providerStatus === 'refunded') { + return 'refunded' + } + if (eventType === 'payment_success') { return 'paid' } @@ -210,6 +351,20 @@ function resolveOrderStatus(eventType, payload) { } function resolvePayStatus(eventType, payload) { + const providerStatus = resolveProviderStatus(payload) + + if (providerStatus === 'paid') { + return 'paid' + } + + if (providerStatus === 'closed') { + return 'unpaid' + } + + if (providerStatus === 'refunded') { + return 'refunded' + } + if (eventType === 'payment_success') { return 'paid' } @@ -234,6 +389,24 @@ function resolvePayStatus(eventType, payload) { } function resolvePaidAt(eventType, payload) { + const providerPaidAt = normalizeProviderDateTime( + pickFirstNonEmpty([ + payload.paid_at, + payload.paidAt, + payload.pay_time, + payload.payTime, + payload.PayTime, + ]), + ) + + if (providerPaidAt) { + return providerPaidAt + } + + if (resolveProviderStatus(payload) === 'paid') { + return nowIso() + } + if (eventType === 'payment_success') { return nowIso() } @@ -266,13 +439,17 @@ function parsePayloadJson(rawJson) { } function normalizeOrderItems(payload) { - const items = Array.isArray(payload.items) && payload.items.length > 0 - ? payload.items - : [payload] + const items = extractOrderItemSources(payload) return items.map((item) => { const source = isPlainObject(item) ? item : {} - const rawSkuKey = pickFirstNonEmpty([ + const rawSkuCandidates = [ + source.OuterSkuId, + source.outerSkuId, + source.outer_sku_id, + source.OuterIid, + source.outerIid, + source.outer_iid, source.sku_code, source.skuCode, source.goods_sku, @@ -280,13 +457,18 @@ function normalizeOrderItems(payload) { source.itemId, source.goods_id, source.goodsId, + source.NumIid, source.num_iid, - ]) - const skuCode = resolveSkuCode(rawSkuKey) + payload.item_id, + payload.itemId, + ] + const skuCode = resolveSkuCode(rawSkuCandidates) return { skuCode, skuName: pickFirstNonEmpty([ + source.Title, + source.title, source.sku_name, source.skuName, source.goods_name, @@ -295,25 +477,204 @@ function normalizeOrderItems(payload) { ]), quantity: Math.max( 1, - normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1, + normalizeInteger( + pickFirstNonEmpty([ + source.quantity, + source.num, + source.Num, + source.buy_amount, + payload.quantity, + payload.num, + payload.Num, + ]), + ) || 1, ), spec: source, } }) } -function resolveSkuCode(rawKey) { - const mappings = runtimeConfig.orders.skuMappings +function extractOrderItemSources(payload) { + const candidates = [ + payload.items, + payload.Items, + payload.orders, + payload.Orders, + payload.order_list, + payload.OrderList, + ] - if (rawKey && mappings && typeof mappings === 'object') { - const direct = mappings[String(rawKey)] - if (typeof direct === 'string' && direct.trim()) { - return direct.trim() + for (const current of candidates) { + if (Array.isArray(current) && current.length > 0) { + return current } } - if (typeof rawKey === 'string' && rawKey.trim()) { - return rawKey.trim() + return [payload] +} + +function resolveBusinessPlatform(rawPlatform) { + const normalized = normalizePlatformKey(rawPlatform) + + if (!normalized) { + return 'unknown' + } + + const aliases = { + xianyu: 'xianyu', + idlefish: 'xianyu', + idle: 'xianyu', + taobaoidle: 'xianyu', + taobaoxianyu: 'xianyu', + taobao: 'taobao', + tb: 'taobao', + pdd: 'pdd', + pinduoduo: 'pdd', + douyin: 'douyin', + weidian: 'weidian', + jd: 'jd', + jingdong: 'jd', + } + + return aliases[normalized] || normalized +} + +function normalizePlatformKey(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, '') +} + +function resolveShop(payload) { + const shopName = pickFirstNonEmpty([ + payload.shop_name, + payload.shopName, + payload.ShopName, + payload.seller_name, + payload.sellerName, + payload.seller_nick, + payload.sellerNick, + payload.SellerNick, + ]) + + return { + shopId: pickFirstNonEmpty([ + payload.shop_id, + payload.shopId, + payload.ShopId, + payload.seller_id, + payload.sellerId, + payload.SellerId, + payload.PlatformUserId, + payload.platformUserId, + shopName, + ]), + shopName, + } +} + +function resolveProviderStatus(payload) { + const rawStatus = normalizeInteger( + pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), + ) + + if (rawStatus === 3 || rawStatus === 4) { + return 'paid' + } + + if (rawStatus === 6) { + return 'closed' + } + + if (rawStatus === 5) { + return 'refunded' + } + + const statusText = pickFirstNonEmpty([ + payload.Status, + payload.status, + payload.order_status_text, + payload.orderStatusText, + ]).toUpperCase() + + if (!statusText) { + return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' + } + + if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) { + return 'paid' + } + + if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) { + return 'closed' + } + + if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) { + return 'refunded' + } + + if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) { + return 'unpaid' + } + + return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' +} + +function normalizeProviderDateTime(value) { + const normalized = String(value || '').trim() + + if (!normalized) { + return null + } + + const isoText = normalized.replace(' ', 'T') + const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/) + + if (utcMatch) { + const [, year, month, day, hours, minutes, seconds] = utcMatch + return new Date( + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hours) - 8, + Number(minutes), + Number(seconds), + ), + ).toISOString() + } + + const date = new Date(normalized) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function resolveSkuCode(rawKey) { + const mappings = runtimeConfig.orders.skuMappings + const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] + + if (mappings && typeof mappings === 'object') { + for (const candidate of candidates) { + const normalizedCandidate = String(candidate || '').trim() + if (!normalizedCandidate) { + continue + } + + const direct = mappings[normalizedCandidate] + if (typeof direct === 'string' && direct.trim()) { + return direct.trim() + } + } + } + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim() + } + + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + return String(candidate) + } } return '' diff --git a/apps/backend/src/utils/http.js b/apps/backend/src/utils/http.js index 2c7d683e..2fde2a8e 100644 --- a/apps/backend/src/utils/http.js +++ b/apps/backend/src/utils/http.js @@ -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}` } diff --git a/apps/backend/src/utils/logger.js b/apps/backend/src/utils/logger.js new file mode 100644 index 00000000..9f1ba90f --- /dev/null +++ b/apps/backend/src/utils/logger.js @@ -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) + } +} diff --git a/apps/backend/subservices/ocr-worker/README.md b/apps/backend/subservices/ocr-worker/README.md index c66db59f..4a4db623 100644 --- a/apps/backend/subservices/ocr-worker/README.md +++ b/apps/backend/subservices/ocr-worker/README.md @@ -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 diff --git a/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py b/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py index 0fd93e59..91cf7bbe 100644 --- a/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py +++ b/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py @@ -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() diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 87015d2a..325e9b11 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -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", diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 809b21d1..8bfb9b38 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,4 +1,4 @@ -{$APP_DOMAIN:localhost} { +{$CADDY_SITE_ADDR:http://localhost} { route { handle /api/* { reverse_proxy backend:3000 diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev new file mode 100644 index 00000000..4de017f0 --- /dev/null +++ b/deploy/caddy/Caddyfile.dev @@ -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 + } + } +} diff --git a/deploy/docker/backend.Dockerfile b/deploy/docker/backend.Dockerfile index 3c771345..bea2e8c9 100644 --- a/deploy/docker/backend.Dockerfile +++ b/deploy/docker/backend.Dockerfile @@ -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/ ./ diff --git a/deploy/docker/dev-caddy.Dockerfile b/deploy/docker/dev-caddy.Dockerfile new file mode 100644 index 00000000..d456939f --- /dev/null +++ b/deploy/docker/dev-caddy.Dockerfile @@ -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 diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile index dbf4c39b..4c1a1521 100644 --- a/deploy/docker/frontend.Dockerfile +++ b/deploy/docker/frontend.Dockerfile @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..2b2ab341 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index c0f03af5..5479908f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/apps/backend/app信息.md b/docs/app信息.md similarity index 86% rename from apps/backend/app信息.md rename to docs/app信息.md index dbbd05c2..3f1600f7 100644 --- a/apps/backend/app信息.md +++ b/docs/app信息.md @@ -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 \ No newline at end of file diff --git a/docs/服务器部署教程.md b/docs/服务器部署教程.md deleted file mode 100644 index 2dbfa717..00000000 --- a/docs/服务器部署教程.md +++ /dev/null @@ -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 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 已在镜像中内置,首次构建时间较长,后续会快很多 diff --git a/docs/生产环境变量模板.env b/docs/生产环境变量模板.env deleted file mode 100644 index a6bdf9ef..00000000 --- a/docs/生产环境变量模板.env +++ /dev/null @@ -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} diff --git a/docs/迁移与部署计划.md b/docs/迁移与部署计划.md deleted file mode 100644 index 34fccf76..00000000 --- a/docs/迁移与部署计划.md +++ /dev/null @@ -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) diff --git a/docs/首次上线检查清单.md b/docs/首次上线检查清单.md deleted file mode 100644 index 16822a98..00000000 --- a/docs/首次上线检查清单.md +++ /dev/null @@ -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. 数据库文件可导出