feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
# 杜康 API 环境变量模板
# 本地开发:复制为 .env 后按需修改(pnpm dev:api 仅读取 .env / .env.local
# 可选 .env.local — 本机覆盖(gitignore
# 服务器:仅使用 .env.productionbash deploy/sync-api-env.sh production
# .env.development — 本地参考模板,不部署到服务器
DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
REDIS_URL="redis://localhost:6017"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
JWT_EXPIRES_IN="7d"
PORT=3000
MOCK_SMS=true
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM=
# 合伙人代下单确认短信模板(PARTNER_PROXY_ORDER);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_TEMPLATE_PROXY_ORDER=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 开启总部人工审核时改为 false:合伙人录店 → 待审核 → HQ 通过后可开门;驳回须填原因并在合伙人端展示
# AUTO_APPROVE_STORE=false
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 并配置 WX_APP_ID / WX_APP_SECRET。
MOCK_WECHAT=true
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
# USER_H5_URL=https://user.runxian.top/user
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
# 微信 SDK(生产:MOCK_WECHAT=false,配置 WX_APP_ID / WX_APP_SECRET;支付关闭 MOCK_PAY 并配置商户号)
# OAuth 授权页由 /common/wechat/oauth-url 生成;三端统一入口 user.runxian.top/{user,shop,partner}。
# 微信服务号「网页授权域名」「JS 接口安全域名」均配置 user.runxian.top(仅 1 个名额)。
WX_APP_ID=
WX_APP_SECRET=
# 小程序(code2session / 小程序支付 appid;须与 apps/mini-user/project.config.json 的 appid 一致)
# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code
WX_MINI_APP_ID=
WX_MINI_APP_SECRET=
# 推广码小程序码落地页(getwxacodeunlimit 的 page;勿前导 /
WX_MINI_PROMO_PAGE=pages/home/index
# 小程序码打开版本:release | trial | develop(默认 release
# WX_MINI_ENV_VERSION=release
WX_MCH_ID=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
WX_REFUND_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/refund
# 小程序消息推送(发货管理确认收货/结算事件):后台 URL 填下方地址,数据格式建议 JSON,加密方式建议安全模式
# https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
WX_MINI_MSG_TOKEN=
WX_MINI_MSG_AES_KEY=
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
WECOM_AIBOT_ENABLED=false
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=local
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
# 未开签名校验时可只填 KEY;SK 勿下发前端
TENCENT_LBS_KEY=
TENCENT_LBS_SECRET_KEY=
# 阿里云 OSSali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_REGION=oss-cn-hangzhou
# 可选:自定义 Endpoint(如内网 oss-cn-hangzhou-internal.aliyuncs.com
OSS_ENDPOINT=
# 可选:开启 V4 签名(需同步调整前端 PostObject 字段,默认 false 使用 V1
OSS_AUTHORIZATION_V4=false
# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
OSS_CDN_BASE=
# 直传对象前缀(默认 uploads)
OSS_UPLOAD_PREFIX=uploads
# 直传凭证有效期(秒,默认 900
OSS_UPLOAD_EXPIRE_SECONDS=900
# 单文件大小上限(字节,默认 10MB)
OSS_MAX_UPLOAD_BYTES=10485760
# 小飞侠同城配送(COURIER_PROVIDER=xiaofeixia
COURIER_PROVIDER=xiaofeixia
XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do
XIAOFEIXIA_MCH_ID=
XIAOFEIXIA_API_KEY=
XIAOFEIXIA_SIGN_TYPE=MD5
# XIAOFEIXIA_APP_ID=
# HQ 订单发货默认寄件信息
SHIP_FROM_NAME=杜康仓库
SHIP_FROM_MOBILE=13800000000
SHIP_FROM_ADDRESS=河南省郑州市金水区
SHIP_FROM_ADDRESS_DETAIL=杜康酒业仓
SHIP_FROM_LNG=113.665
SHIP_FROM_LAT=34.757
# Admin 概览「发布更新」:调用本机 deploy webhook(与 deploy/auto-release.env 中 SECRET 一致)
# DEPLOY_WEBHOOK_URL=http://127.0.0.1:8095/deploy
# DEPLOY_WEBHOOK_SECRET=change-me-to-a-long-random-string
# Sentry DSN 在 HQ「系统设置 → 发布部署」维护(SENTRY_DSN);本地调试可临时写入 .env.local
+68
View File
@@ -0,0 +1,68 @@
# 生产环境(真实第三方,关闭 Mock)
# 用途:服务器 /opt/dukangbash deploy/sync-api-env.sh production
# 本地开发请使用 .env(从 .env.example 复制),勿与生产混用
# 测试环境请使用 .env.staging(从 .env.staging.example 复制)
APP_ENV=production
NODE_ENV=production
# 生产:阿里云 RDS(密码中的 @ ! 需 URL 编码为 %40 %21
DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_prod?charset=utf8mb4"
REDIS_URL="redis://localhost:6379/0"
JWT_SECRET="CHANGE_ME-strong-random-secret"
JWT_EXPIRES_IN="7d"
PORT=8090
MOCK_SMS=false
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 核销确认(REDEEM_PHONE_CONFIRM
ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM=
# 合伙人代下单确认(PARTNER_PROXY_ORDER
ALIYUN_SMS_TEMPLATE_PROXY_ORDER=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=false
MOCK_DELIVERY_AUTO=false
AUTO_APPROVE_STORE=false
TRUST_PROXY=true
# C 端 H5 落地页(推广码二维码;生产统一入口)
USER_H5_URL=https://user.runxian.top/user
MOCK_WECHAT=false
WX_APP_ID=
WX_APP_SECRET=
WX_MINI_APP_ID=
WX_MINI_APP_SECRET=
WX_MINI_PROMO_PAGE=pages/home/index
# WX_MINI_ENV_VERSION=release
WX_MCH_ID=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
WX_REFUND_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/refund
# 消息推送 URL: https://api.dukanghaoke.com/api/v1/callbacks/wechat/message
WX_MINI_MSG_TOKEN=
WX_MINI_MSG_AES_KEY=
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
WECOM_AIBOT_ENABLED=false
# 运营告警:企业微信群机器人 Webhook
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=production
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev
OSS_REGION=oss-cn-beijing
OSS_CDN_BASE=https://dukang-dev.oss-cn-beijing.aliyuncs.com
OSS_UPLOAD_PREFIX=uploads
OSS_UPLOAD_EXPIRE_SECONDS=900
OSS_MAX_UPLOAD_BYTES=10485760
+74
View File
@@ -0,0 +1,74 @@
# Staging / 测试环境(同机双栈,正式微信 AppId + 全 Mock
# 用途:服务器 /opt/dukang-stagingsync: bash deploy/sync-api-env.sh staging
# 域名:api-test / user-test / shop-test / partner-test / admin-test.dukanghaoke.com
# 本地开发请用 .env,勿与本文件混用
APP_ENV=staging
NODE_ENV=production
# 独立库(与生产 dukang_prod 隔离;可同 RDS 实例)
DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_staging?charset=utf8mb4"
# 建议用 Redis DB 1,与生产 DB 0 隔离
REDIS_URL="redis://localhost:6379/1"
JWT_SECRET="CHANGE_ME-staging-secret-different-from-prod"
JWT_EXPIRES_IN="7d"
PORT=8190
# 全 Mock:不发真实短信、不扣真实微信支付
MOCK_SMS=true
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
MOCK_WECHAT=true
AUTO_APPROVE_STORE=true
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM=
ALIYUN_SMS_TEMPLATE_PROXY_ORDER=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
TRUST_PROXY=true
# C 端 H5(测试域)
USER_H5_URL=https://user-test.dukanghaoke.com/user
# 正式号配置可与生产相同,但 Mock 打开后不走真实支付
WX_APP_ID=
WX_APP_SECRET=
WX_MINI_APP_ID=
WX_MINI_APP_SECRET=
WX_MINI_PROMO_PAGE=pages/home/index
# WX_MINI_ENV_VERSION=trial
WX_MCH_ID=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay
WX_REFUND_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/refund
WX_MINI_MSG_TOKEN=
WX_MINI_MSG_AES_KEY=
WECOM_AIBOT_ENABLED=false
# 运营告警:企业微信群机器人 Webhook
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=staging
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=dukang-dev
OSS_REGION=oss-cn-beijing
OSS_CDN_BASE=https://dukang-dev.oss-cn-beijing.aliyuncs.com
OSS_UPLOAD_PREFIX=staging/uploads
OSS_UPLOAD_EXPIRE_SECONDS=900
OSS_MAX_UPLOAD_BYTES=10485760
COURIER_PROVIDER=xiaofeixia
XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do
XIAOFEIXIA_MCH_ID=
XIAOFEIXIA_API_KEY=
XIAOFEIXIA_SIGN_TYPE=MD5
+75
View File
@@ -0,0 +1,75 @@
# 杜康好客 · 后端 AGENTS.md
> 父级:[`../AGENTS.md`](../AGENTS.md) · API 契约:V2 手册 §六 · 表结构:§五
## 范围
本目录 = 唯一后端进程 `dukang-api`NestJS 10 + Prisma 5)。
## 模块 → 表 OWNER(写权限)
| Module | 主要 Prisma Model | 负责人 |
|--------|-------------------|--------|
| **iam** | User, UserAddress, … | jacy-dukang |
| **catalog** | CommonCity, CommonProductItem, … | jacy-dukang |
| **store** | Store, Partner | 刘景尧 |
| **trade** | Order, OrderDelivery | jacy-dukang |
| **benefit** | BenefitCoupon | jacy-dukang |
| **redeem** | RedeemRecord, StoreRating | 刘景尧 |
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
| **ops** | 只读聚合 | jacy-dukang |
| **analytics** | LogUserAnalytics | jacy-dukang |
| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
| **integrations** | 无表 | jacy-dukang |
**log_***`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
## 允许依赖(摘要)
```
trade → catalog, store, benefit, iam, notify, common, domain
benefit → iam, common, domain(禁止 → trade/redeem
redeem → benefit, store, settlement, iam, notify, common, domain
store → catalog, iam, common, domain(禁止 → trade/benefit
settlement → trade, redeem, store, catalog, iam, notify, common, domain
callbacks → trade, benefit, settlement, common(薄层,无业务表)
jobs → trade, settlement, notify, common
```
完整矩阵见 V2 手册 §四 §2.5.2。
## 新接口 checklist
1.**OWNER 模块**`dto/``*.controller.ts``*.service.ts`
2. Service 只写本模块表;跨域 inject exported Service
3. Guard`JwtAuthGuard` + `actorType` 校验(User/Store/Partner/HQ
4. 响应 `{ code: 0, message: 'ok', data }``ResponseInterceptor`
5. DTO/枚举同步 `packages/shared-types`
6. preV1 Mock:改 `integrations/*`,不在 Controller 散落 `if (mock)`
## Mock 集成(preV1
```
integrations/
├── sms/sms.mock.provider.ts
├── pay/pay.mock.provider.ts
└── delivery/delivery.mock.provider.ts
```
开关见 `packages/shared-types/src/config.ts``MOCK_SMS`, `MOCK_PAY`, `MOCK_DELIVERY_AUTO`)。
## 常用命令
```bash
pnpm dev:api
pnpm db:validate
pnpm db:seed
cd server/dukang-api && npx prisma studio
```
## 禁止
-`redeem``prisma.order.update` → 调 `TradeService`
-`benefit` 里 inject `TradeService` / `RedeemService`
- 多个 Module 注册同一 callback 路由
- 复制起购/核销规则到 Controller → 用 `packages/domain`
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@dukang/api",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"lint": "eslint src",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
"prisma:seed-finance": "ts-node --transpile-only prisma/seed-finance-mock.ts",
"prisma:seed-stats": "ts-node --transpile-only prisma/seed-stats-mock.ts",
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
"@alicloud/openapi-client": "^0.4.15",
"@dukang/domain": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@nestjs/bullmq": "^10.2.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0",
"@nestjs/schedule": "^6.1.3",
"@prisma/client": "^5.18.0",
"@sentry/node": "^10.69.0",
"@wecom/aibot-node-sdk": "^1.0.7",
"ali-oss": "^6.23.0",
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@types/ali-oss": "^6.23.3",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
}
+282
View File
@@ -0,0 +1,282 @@
# V3.1 Schema 迁移清单
> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`
> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`
## 激活步骤(P0
```bash
cd server/dukang-api
# 1. 备份并切换 schema
cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份
cp prisma/schema.v31.prisma prisma/schema.prisma
# 2. 开发库建议删库重建
mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..."
# 或:mysql < prisma/init_v3.sql
# 3. 生成 Client
pnpm db:generate
npx prisma db push # 或 prisma migrate dev --name v31_init
# 4. 新 seed(待编写 seed-v31.ts
pnpm prisma:seed
```
---
## 表对照(27 张 v3.1
| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 |
|-------------|--------------|------------|------|
| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 |
| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL |
| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** |
| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** |
| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` |
| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 |
| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` |
| `common_city` | `CommonCity` | `cities` / `City` | 重命名 |
| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 |
| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 |
| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 |
| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 |
| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 |
| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 |
| `user_address` | `UserAddress` | `user_addresses` | 重命名 |
| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 |
| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 |
| `store_store` | `Store` | `stores` | `cover_url``cover_resource_id` |
| `store_account` | `StoreAccount` | `store_accounts` | 重命名 |
| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 |
| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` |
| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 |
| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 |
| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 |
| `store_payout` | `StorePayout` | `store_payouts` | 重命名 |
| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** |
| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** |
### 删除的表(v3.1 不再存在)
| 旧表 | 替代方案 |
|------|----------|
| `store_media` | `common_resource``owner_type=STORE` |
| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT |
| `order_items` | `user_order` 内嵌快照字段 |
| `order_status_logs` | `common_event(ORDER_STATUS)` |
| `payments` | `log_third_party` + `user_order.pay_*` |
| `refunds` | `common_ticket(REFUND)` |
| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT |
| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` |
| `redeem_tokens` | **仅 Redis** |
| `order_commissions` | `partner_bill` 汇总(无明细表) |
| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) |
| `store_audits` | `common_event(STORE_AUDIT)` |
| `after_sale_tickets` | `common_ticket` |
| `alerts` | `common_ticket(ALERT)` |
| `operation_logs` | `common_event(HQ_OPERATION)` |
| `event_logs` | `common_event` + `log_user_analytics` |
### preV1 扩展字段(已并入 v3.1
| 字段 | 表 | 说明 |
|------|-----|------|
| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 |
| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 |
~~以下字段在切换后丢失~~**已保留**
---
## Prisma Client 调用变更速查
| 旧调用 | v3.1 调用 |
|--------|-----------|
| `prisma.product` | `prisma.commonProductItem` |
| `prisma.city` | `prisma.commonCity` |
| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` |
| `prisma.storeCategory` | `prisma.commonStoreCategory` |
| `prisma.promoCode` | `prisma.commonPromoCode` |
| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery` |
| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon` |
| `prisma.benefitLedger` | `prisma.commonEvent``eventType=BENEFIT_LEDGER` |
| `prisma.redeemToken` | **删除**,改 Redis |
| `prisma.storeMedia` | `prisma.commonResource` |
| `prisma.storeAudit` | `prisma.commonEvent``eventType=STORE_AUDIT` |
| `prisma.payment` | `prisma.logThirdParty` |
| `prisma.orderStatusLog` | `prisma.commonEvent``eventType=ORDER_STATUS` |
| `prisma.orderItem` | **删除**,读写 `order` 快照字段 |
> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。
---
## 后端模块影响面
### P0 — 基础设施
| 路径 | 影响 | 工作量 |
|------|------|--------|
| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ |
| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ |
| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ |
| `prisma/sync-benefit-to-price.ts` | `product``commonProductItem` | 低 |
| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 |
### P1 — common 模块(新建)
| 路径 | 说明 |
|------|------|
| `src/modules/common/common.module.ts` | **新建** |
| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD |
| `src/modules/common/resource.controller.ts` | `/common/resources/*` |
| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 |
| `src/modules/common/ticket.service.ts` | 工单 CRUD |
### P2 — IAM
| 路径 | 关键改动 |
|------|----------|
| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 |
| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 |
| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 |
| `common/guards/super-admin.guard.ts` | 无大变 |
### P3 — catalog
| 路径 | 关键改动 |
|------|----------|
| `modules/catalog/catalog.service.ts` | `city``commonCity``product``commonProductItem`;返回 `coverResource.url` |
### P4 — trade(改动最大)
| 路径 | 关键改动 |
|------|----------|
| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 |
| `integrations/pay/*` | 回调改查 `log_third_party` |
| `jobs/*`(配送 Mock | `orderDelivery` 字段对齐 |
### P5 — benefit
| 路径 | 关键改动 |
|------|----------|
| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create``commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` |
### P6 — redeem
| 路径 | 关键改动 |
|------|----------|
| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis`cityCommissionRule``commonCityCommissionRule` |
### P7 — store
| 路径 | 关键改动 |
|------|----------|
| `modules/store/store.service.ts` | `storeAudit``commonEvent`;封面改 `coverResourceId``city``commonCity` |
### P8 — settlement
| 路径 | 关键改动 |
|------|----------|
| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 |
### P9 — analytics
| 路径 | 关键改动 |
|------|----------|
| `modules/analytics/analytics.service.ts` | `eventLog``logUserAnalytics` |
### P10 — opsHQ 后台)
| 路径 | 关键改动 |
|------|----------|
| `modules/ops/admin-stores.service.ts` | `storeMedia``commonResource``coverUrl``coverResourceId``city``commonCity` |
| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` |
| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include |
| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 |
| `modules/ops/admin-cities.service.ts` | `city``commonCity` |
| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 |
| `modules/ops/admin-redeem.service.ts` | 表名映射 |
| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 |
| `admin-stores.controller.ts` | `/admin/store-media``/admin/resources` 或复用 common API |
---
## 前端影响面
| 应用 | 影响 |
|------|------|
| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 |
| `apps/h5-shop` | 门店详情封面 URL |
| `apps/h5-partner` | 录店上传走 `/common/resources` |
| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 |
| `packages/shared-types` | 枚举与 DTO 同步 |
---
## P1 进度(common 模块)
| 项 | 状态 |
|----|------|
| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ |
| `common/event/event.helpers.ts` 权益/订单事件 | ✅ |
| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ |
| `pnpm run build` | ✅ |
## P2–P6 进度(业务层 + 兼容层 + 后台)
| 项 | 状态 |
|----|------|
| P2 IAM + catalog 适配 v3.1 | ✅ |
| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ |
| P4 redeem + storeRedis token + 封面资源) | ✅ |
| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ |
| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ |
| `admin/products` CRUD + HQ 商品页 | ✅ |
| `sync-benefit-to-price.ts``commonProductItem` | ✅ |
| `schema.prisma``schema.v31.prisma` 同步 | ✅ |
| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ |
### 新增 HQ API
| 路径 | 说明 |
|------|------|
| `GET/POST /admin/products` | 商品列表/新建 |
| `GET/PUT /admin/products/:id` | 商品详情/更新 |
### 新增 API`/api/v1/common/*`
| 路径 | 说明 |
|------|------|
| `POST /common/resources/upload-token` | Mock OSS 直传凭证 |
| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD |
| `POST/GET /common/events` | 事件写入/查询 |
| `GET /common/events/timeline` | 时间线 |
| `POST/GET/PUT /common/tickets` | 工单 |
| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 |
## 建议实施顺序
```
P0 schema 切换 + seed-v31
→ P1 common 模块(resource + event
→ P2 IAM + catalog(可登录、可看商品)
→ P3 trade + benefit(可下单发券)
→ P4 redeem + store(可核销)
→ P5 ops 后台 + settlement
→ P6 前端对齐 + smoke
```
**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。
---
## 文件索引
| 文件 | 说明 |
|------|------|
| `prisma/init_v3.sql` | DDL 源 |
| `prisma/schema.v31.prisma` | **新生成**,待激活 |
| `prisma/schema.legacy-v21.prisma` | 旧版备份 |
| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活** |
+30
View File
@@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/** 清空开城城市及关联数据(保留 HQ 账户、商品目录等) */
async function main() {
await prisma.logPartnerAnalytics.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany({ where: { refType: { in: ['CITY', 'WAREHOUSE', 'PARTNER', 'PARTNER_ACCOUNT', 'STORE'] } } });
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
const result = await prisma.commonCity.deleteMany();
console.log(`Cleared ${result.count} cities and related open-city data.`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,36 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
async function ensureColumn(table, column, ddl) {
const cols = await p.$queryRawUnsafe(`SHOW COLUMNS FROM \`${table}\` LIKE '${column}'`);
if (!cols.length) {
await p.$executeRawUnsafe(ddl);
console.log(`added ${table}.${column}`);
} else {
console.log(`${table}.${column} exists`);
}
}
(async () => {
await ensureColumn(
'wecom_bot',
'ai_enabled',
'ALTER TABLE `wecom_bot` ADD COLUMN `ai_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `permissions`',
);
await ensureColumn(
'wecom_bot',
'llm_config_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `llm_config_id` BIGINT UNSIGNED NULL AFTER `ai_enabled`',
);
await ensureColumn(
'wecom_bot',
'knowledge_base_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `knowledge_base_id` BIGINT UNSIGNED NULL AFTER `llm_config_id`',
);
await p.$disconnect();
})().catch(async (e) => {
console.error(e);
await p.$disconnect();
process.exit(1);
});
+670
View File
@@ -0,0 +1,670 @@
============================================================
-- 杜康好客 V3.1 数据库初始化脚本
-- MySQL 8.0+ utf8mb4_unicode_ci InnoDB
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE DATABASE IF NOT EXISTS dukang_haoke
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE dukang_haoke;
-- ===================== COMMON =============================
DROP TABLE IF EXISTS common_wx_app_config;
CREATE TABLE common_wx_app_config (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
client_app VARCHAR(32) NOT NULL COMMENT 'USER_MINI|PARTNER_MINI|HQ_MINI|SHOP_H5',
app_id VARCHAR(64) NOT NULL,
app_secret VARCHAR(128) NOT NULL,
mch_id VARCHAR(32) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_wx_app_config_client (client_app)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='四端微信配置';
DROP TABLE IF EXISTS common_resource;
CREATE TABLE common_resource (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
owner_type VARCHAR(32) NOT NULL COMMENT 'PRODUCT|STORE|PARTNER|USER|ORDER|PROMO|HQ',
owner_id BIGINT UNSIGNED NOT NULL COMMENT '归属业务ID',
biz_type VARCHAR(32) NOT NULL COMMENT 'COVER|ENV|CONTRACT|CAROUSEL|DETAIL|AVATAR|QRCODE|SIGN_PHOTO|VIDEO',
media_type VARCHAR(16) NOT NULL DEFAULT 'IMAGE' COMMENT 'IMAGE|VIDEO|FILE',
oss_bucket VARCHAR(64) NOT NULL COMMENT 'OSS Bucket',
oss_key VARCHAR(256) NOT NULL COMMENT 'OSS Object Key',
url VARCHAR(512) NOT NULL COMMENT 'CDN访问URL',
file_name VARCHAR(128) DEFAULT NULL,
file_size BIGINT UNSIGNED DEFAULT NULL COMMENT '字节',
mime_type VARCHAR(64) DEFAULT NULL,
sort_order INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE|DELETED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_common_resource_owner (owner_type, owner_id, biz_type),
KEY idx_common_resource_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一资源表(OSS)';
DROP TABLE IF EXISTS common_event;
CREATE TABLE common_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_type VARCHAR(32) NOT NULL COMMENT 'STORE_AUDIT|ORDER_STATUS|BENEFIT_LEDGER|HQ_OPERATION|PROMO_TOUCH',
ref_type VARCHAR(32) NOT NULL COMMENT 'ORDER|STORE|BENEFIT_COUPON|USER|PRODUCT|...',
ref_id BIGINT UNSIGNED NOT NULL,
actor_type VARCHAR(16) DEFAULT NULL COMMENT 'USER|STORE|PARTNER|HQ|SYSTEM',
actor_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(32) DEFAULT NULL COMMENT '事件子状态(如审核PENDING/APPROVED)',
param1 VARCHAR(128) DEFAULT NULL,
param1_desc VARCHAR(64) DEFAULT NULL,
param2 VARCHAR(128) DEFAULT NULL,
param2_desc VARCHAR(64) DEFAULT NULL,
param3 VARCHAR(128) DEFAULT NULL,
param3_desc VARCHAR(64) DEFAULT NULL,
amount1 DECIMAL(10,2) DEFAULT NULL COMMENT '仅BENEFIT_LEDGER:变动额',
amount2 DECIMAL(10,2) DEFAULT NULL COMMENT '仅BENEFIT_LEDGER:变动后余额',
remark VARCHAR(512) DEFAULT NULL,
extra_json JSON DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_common_event_ref (ref_type, ref_id, event_type),
KEY idx_common_event_type_created (event_type, created_at),
KEY idx_common_event_actor (actor_type, actor_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一事件表';
DROP TABLE IF EXISTS common_ticket;
CREATE TABLE common_ticket (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_no VARCHAR(32) NOT NULL,
ticket_type VARCHAR(32) NOT NULL COMMENT 'REFUND|RESHIPMENT|ALERT',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
ref_type VARCHAR(32) NOT NULL COMMENT 'ORDER|STORE|PARTNER|...',
ref_id BIGINT UNSIGNED NOT NULL,
operator_type VARCHAR(16) DEFAULT NULL COMMENT 'HQ|PARTNER|SYSTEM',
operator_id BIGINT UNSIGNED DEFAULT NULL,
param1 VARCHAR(128) DEFAULT NULL,
param1_desc VARCHAR(64) DEFAULT NULL,
param2 VARCHAR(128) DEFAULT NULL,
param2_desc VARCHAR(64) DEFAULT NULL,
param3 VARCHAR(128) DEFAULT NULL,
param3_desc VARCHAR(64) DEFAULT NULL,
remark VARCHAR(512) DEFAULT NULL,
extra_json JSON DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_common_ticket_no (ticket_no),
KEY idx_common_ticket_ref (ref_type, ref_id),
KEY idx_common_ticket_type_status (ticket_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用工单表';
DROP TABLE IF EXISTS common_support_ticket;
CREATE TABLE common_support_ticket (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ticket_no VARCHAR(32) NOT NULL,
ticket_type VARCHAR(32) NOT NULL COMMENT 'BUG|SUGGESTION|OTHER',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT 'PENDING_REVIEW|REJECTED|DEVELOPING|TESTING|PASSED',
title VARCHAR(128) NOT NULL,
content TEXT DEFAULT NULL,
reject_reason VARCHAR(512) DEFAULT NULL,
creator_id BIGINT UNSIGNED NOT NULL,
creator_name VARCHAR(64) NOT NULL,
reviewer_id BIGINT UNSIGNED DEFAULT NULL,
reviewer_name VARCHAR(64) DEFAULT NULL,
reviewed_at DATETIME(3) DEFAULT NULL,
remark VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_common_support_ticket_no (ticket_no),
KEY idx_common_support_ticket_status (status, created_at),
KEY idx_common_support_ticket_type_status (ticket_type, status),
KEY idx_common_support_ticket_creator (creator_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='HQ技术支持工单';
DROP TABLE IF EXISTS common_product_item;
CREATE TABLE common_product_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
sku_code VARCHAR(32) NOT NULL COMMENT 'SKU编码',
barcode_69 VARCHAR(32) NOT NULL COMMENT '69码(商品条码)',
name VARCHAR(128) NOT NULL,
subtitle VARCHAR(256) DEFAULT NULL,
aroma_type VARCHAR(16) NOT NULL COMMENT 'QINGXIANG|JIANGXIANG|NONGXIANG',
spec VARCHAR(128) NOT NULL,
price DECIMAL(10,2) NOT NULL,
benefit_amount DECIMAL(10,2) DEFAULT NULL COMMENT 'NULL=等同售价',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT|ON_SALE|OFF_SALE',
sort_order INT NOT NULL DEFAULT 0,
cover_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '主图 common_resource.id',
detail_content JSON DEFAULT NULL COMMENT '图文详情(纯文本结构)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_product_item_sku (sku_code),
UNIQUE KEY uk_common_product_item_barcode (barcode_69),
KEY idx_common_product_item_status (status, aroma_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品SKU';
DROP TABLE IF EXISTS common_store_category;
CREATE TABLE common_store_category (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(64) NOT NULL,
sort INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_common_store_category_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店餐饮分类';
DROP TABLE IF EXISTS common_promo_code;
CREATE TABLE common_promo_code (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK',
qrcode_id VARCHAR(64) NOT NULL COMMENT '二维码唯一识别码',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
owner_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联用户(统计/归因)',
remark VARCHAR(256) DEFAULT NULL,
qrcode_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '小程序码 common_resource.id',
scan_count INT NOT NULL DEFAULT 0,
order_count INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_promo_code_code (code),
UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id),
KEY idx_common_promo_code_owner (owner_user_id),
KEY idx_common_promo_code_scene_status (scene, status),
CONSTRAINT fk_common_promo_code_owner_user FOREIGN KEY (owner_user_id) REFERENCES user_user(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
DROP TABLE IF EXISTS common_city;
CREATE TABLE common_city (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(16) NOT NULL,
name VARCHAR(64) NOT NULL,
province VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
local_min_qty INT NOT NULL DEFAULT 2,
cross_min_qty INT NOT NULL DEFAULT 6,
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_city_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
DROP TABLE IF EXISTS partner_account;
CREATE TABLE partner_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
permissions JSON DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
city_id BIGINT UNSIGNED DEFAULT NULL COMMENT '主账号绑定城市',
scope_type VARCHAR(16) DEFAULT NULL COMMENT 'CITY_WIDE|DISTRICT',
district_codes JSON DEFAULT NULL,
order_commission_rate DECIMAL(5,4) DEFAULT 0.0000,
redeem_commission_rate DECIMAL(5,4) DEFAULT 0.0300,
binding_status VARCHAR(16) DEFAULT 'ACTIVE',
company_name VARCHAR(128) DEFAULT NULL,
address VARCHAR(256) DEFAULT NULL,
contact_phone VARCHAR(20) DEFAULT NULL,
contract_no VARCHAR(64) DEFAULT NULL,
contract_signed_at DATETIME(3) DEFAULT NULL,
contract_expire_at DATETIME(3) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
weekly_store_target INT DEFAULT 20,
managed_warehouse_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_account_phone (phone),
UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
KEY idx_partner_account_city_scope (city_id, scope_type),
KEY idx_partner_account_city_primary (city_id, is_primary),
KEY idx_partner_account_parent (parent_account_id),
KEY idx_partner_account_contact_phone (contact_phone),
CONSTRAINT fk_partner_account_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_partner_account_parent FOREIGN KEY (parent_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人账号(主账号=实体+城市绑定)';
DROP TABLE IF EXISTS common_city_warehouse;
CREATE TABLE common_city_warehouse (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
address VARCHAR(256) NOT NULL,
contact_name VARCHAR(64) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
manager_type VARCHAR(16) NOT NULL COMMENT 'HQ|PARTNER',
partner_account_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_city_warehouse_city (city_id),
KEY idx_city_warehouse_partner_account (partner_account_id),
CONSTRAINT fk_city_warehouse_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE,
CONSTRAINT fk_city_warehouse_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市仓库';
ALTER TABLE partner_account
ADD CONSTRAINT fk_partner_account_managed_warehouse FOREIGN KEY (managed_warehouse_id) REFERENCES common_city_warehouse(id) ON DELETE SET NULL;
DROP TABLE IF EXISTS partner_bill;
CREATE TABLE partner_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
confirmed_at DATETIME(3) DEFAULT NULL,
paid_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_bill_no (bill_no),
KEY idx_partner_bill_account_status (partner_account_id, status),
CONSTRAINT fk_partner_bill_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人T+30账单';
-- ===================== HQ =============================
DROP TABLE IF EXISTS hq_account;
CREATE TABLE hq_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
admin_role VARCHAR(32) NOT NULL DEFAULT 'OPS',
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_hq_account_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='总部账号';
-- ===================== USER =============================
DROP TABLE IF EXISTS user_user;
CREATE TABLE user_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_no VARCHAR(20) NOT NULL,
device_key VARCHAR(36) DEFAULT NULL COMMENT '访客设备标识',
phone VARCHAR(20) DEFAULT NULL COMMENT '验机后必填;访客可为NULL',
phone_verified_at DATETIME(3) DEFAULT NULL,
merged_into_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '合并入主账号',
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
nickname VARCHAR(64) DEFAULT NULL,
avatar_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '头像 common_resource.id',
status TINYINT NOT NULL DEFAULT 1,
source_type VARCHAR(32) NOT NULL DEFAULT 'ORGANIC' COMMENT 'ORGANIC|PROMO_CODE|SHARE_LINK|FRIEND_REFERRAL|OFFLINE_EVENT|OTHER',
source_ref_id BIGINT UNSIGNED DEFAULT NULL COMMENT 'promo_code_id 或 referrer_user_id',
source_label VARCHAR(128) DEFAULT NULL COMMENT '渠道名称快照',
referrer_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '好友推荐人 user_user.id',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_user_phone (phone),
UNIQUE KEY uk_user_user_no (user_no),
UNIQUE KEY uk_user_user_device_key (device_key),
KEY idx_user_user_source (source_type, source_ref_id),
KEY idx_user_user_referrer (referrer_user_id),
KEY idx_user_user_merged (merged_into_user_id),
KEY idx_user_user_wx_open (wx_open_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户';
DROP TABLE IF EXISTS user_address;
CREATE TABLE user_address (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
receiver_name VARCHAR(32) NOT NULL,
phone VARCHAR(20) NOT NULL,
province VARCHAR(32) NOT NULL,
city VARCHAR(32) NOT NULL,
district VARCHAR(32) NOT NULL,
detail VARCHAR(256) NOT NULL,
latitude DECIMAL(10,7) DEFAULT NULL,
longitude DECIMAL(10,7) DEFAULT NULL,
is_default TINYINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_user_address_user (user_id),
CONSTRAINT fk_user_address_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户收货地址';
DROP TABLE IF EXISTS user_city_preference;
CREATE TABLE user_city_preference (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
selected_city_code VARCHAR(16) DEFAULT NULL,
selected_district VARCHAR(32) DEFAULT NULL,
locate_city_code VARCHAR(16) DEFAULT NULL,
locate_district VARCHAR(32) DEFAULT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_city_preference_user (user_id),
CONSTRAINT fk_user_city_preference_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户城市偏好';
DROP TABLE IF EXISTS user_promo_attribution;
CREATE TABLE user_promo_attribution (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
promo_code_id BIGINT UNSIGNED NOT NULL,
channel_name VARCHAR(128) NOT NULL,
first_touch_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_user_promo_attribution_user (user_id),
KEY idx_user_promo_attribution_promo (promo_code_id),
CONSTRAINT fk_user_promo_attribution_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE CASCADE,
CONSTRAINT fk_user_promo_attribution_promo FOREIGN KEY (promo_code_id) REFERENCES common_promo_code(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广首次触达(统计)';
-- ===================== STORE =============================
DROP TABLE IF EXISTS store_store;
CREATE TABLE store_store (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED DEFAULT NULL,
name VARCHAR(128) NOT NULL,
phone VARCHAR(20) NOT NULL,
province VARCHAR(32) NOT NULL,
city_name VARCHAR(32) NOT NULL COMMENT '市(冗余)',
district VARCHAR(32) NOT NULL,
address VARCHAR(256) NOT NULL,
latitude DECIMAL(10,7) DEFAULT NULL,
longitude DECIMAL(10,7) DEFAULT NULL,
intro TEXT DEFAULT NULL,
cover_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '门头图',
avg_price DECIMAL(10,2) DEFAULT NULL,
rating DECIMAL(3,2) DEFAULT NULL,
tags JSON DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PAUSED',
open_time VARCHAR(8) DEFAULT NULL,
close_time VARCHAR(8) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000 COMMENT '门店核销结算比例',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_store_store_city_status (city_id, status),
KEY idx_store_store_partner_account (partner_account_id),
CONSTRAINT fk_store_store_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='餐饮门店';
DROP TABLE IF EXISTS store_account;
CREATE TABLE store_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
store_id BIGINT UNSIGNED NOT NULL,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_store_account_store (store_id),
UNIQUE KEY uk_store_account_phone (phone),
CONSTRAINT fk_store_account_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店H5账号';
-- ===================== USER ORDER =============================
DROP TABLE IF EXISTS user_order_item;
DROP TABLE IF EXISTS user_order_delivery;
DROP TABLE IF EXISTS user_order;
CREATE TABLE user_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
order_type VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT 'NORMAL|RESHIPMENT',
user_id BIGINT UNSIGNED NOT NULL,
city_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PAY',
pay_status VARCHAR(16) NOT NULL DEFAULT 'UNPAID' COMMENT 'UNPAID|PAYING|PAID|REFUNDING|REFUNDED',
delivery_type VARCHAR(16) NOT NULL COMMENT 'LOCAL|CROSS_CITY',
origin_order_id BIGINT UNSIGNED DEFAULT NULL,
promo_code_id BIGINT UNSIGNED DEFAULT NULL,
channel_source VARCHAR(128) DEFAULT NULL,
product_id BIGINT UNSIGNED NOT NULL COMMENT '商品 common_product_item.id',
barcode_69 VARCHAR(32) NOT NULL COMMENT '69码快照',
product_name VARCHAR(128) NOT NULL COMMENT '商品名称快照',
product_spec VARCHAR(128) NOT NULL COMMENT '规格快照',
image_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '商品图快照 common_resource.id',
quantity INT NOT NULL COMMENT '购买数量',
list_unit_price DECIMAL(10,2) NOT NULL COMMENT '标价单价',
list_amount DECIMAL(10,2) NOT NULL COMMENT '标价总额',
discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额',
product_amount DECIMAL(10,2) NOT NULL COMMENT '商品应付',
freight_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '运费',
freight_pay_type VARCHAR(8) DEFAULT NULL COMMENT 'FREE|COD',
pay_amount DECIMAL(10,2) NOT NULL COMMENT '实付总额',
benefit_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '本单发放权益',
receiver_name VARCHAR(32) NOT NULL,
receiver_phone VARCHAR(20) NOT NULL,
receiver_address TEXT NOT NULL,
receiver_province VARCHAR(32) NOT NULL,
receiver_city VARCHAR(32) NOT NULL,
receiver_district VARCHAR(32) NOT NULL,
client_ip VARCHAR(45) DEFAULT NULL COMMENT '下单时客户端IP',
ip_province VARCHAR(32) DEFAULT NULL COMMENT 'IP解析省',
ip_city VARCHAR(32) DEFAULT NULL COMMENT 'IP解析市',
ip_district VARCHAR(32) DEFAULT NULL COMMENT 'IP解析区县',
gps_province VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析省',
gps_city VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析市',
gps_district VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析区县',
gps_latitude DECIMAL(10,7) DEFAULT NULL,
gps_longitude DECIMAL(10,7) DEFAULT NULL,
gps_address VARCHAR(256) DEFAULT NULL COMMENT 'GPS逆地理地址',
pay_external_no VARCHAR(64) DEFAULT NULL COMMENT '微信交易号(冗余)',
paid_at DATETIME(3) DEFAULT NULL COMMENT '支付时间',
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间',
cancelled_at DATETIME(3) DEFAULT NULL COMMENT '取消时间',
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
partner_account_id_at_pay BIGINT UNSIGNED DEFAULT NULL,
order_commission_rate_at_pay DECIMAL(5,4) DEFAULT NULL,
remark VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_order_no (order_no),
KEY idx_user_order_user_status (user_id, status),
KEY idx_user_order_city_created (city_id, created_at),
KEY idx_user_order_product (product_id),
KEY idx_user_order_barcode (barcode_69),
KEY idx_user_order_pay_external (pay_external_no),
KEY idx_user_order_ip_city (ip_city),
KEY idx_user_order_gps_city (gps_city),
CONSTRAINT fk_user_order_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_origin FOREIGN KEY (origin_order_id) REFERENCES user_order(id) ON DELETE SET NULL,
CONSTRAINT fk_user_order_promo FOREIGN KEY (promo_code_id) REFERENCES common_promo_code(id) ON DELETE SET NULL,
CONSTRAINT fk_user_order_product FOREIGN KEY (product_id) REFERENCES common_product_item(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单(含商品快照,V1单SKU)';
DROP TABLE IF EXISTS user_order_delivery;
CREATE TABLE user_order_delivery (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(16) NOT NULL COMMENT 'XFX|LOGISTICS|MANUAL',
provider_order_no VARCHAR(64) DEFAULT NULL,
tracking_no VARCHAR(64) DEFAULT NULL,
out_warehouse_at DATETIME(3) DEFAULT NULL,
shipping_at DATETIME(3) DEFAULT NULL,
delivered_at DATETIME(3) DEFAULT NULL,
sign_photo_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '签收照片 common_resource.id',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_order_delivery_order (order_id),
CONSTRAINT fk_user_order_delivery_order FOREIGN KEY (order_id) REFERENCES user_order(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单配送(与user_order 1:1)';
-- ===================== BENEFIT & REDEEM =============================
DROP TABLE IF EXISTS user_benefit_coupon;
CREATE TABLE user_benefit_coupon (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
coupon_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
used_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
balance DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
source_product VARCHAR(128) NOT NULL,
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_benefit_coupon_no (coupon_no),
KEY idx_user_benefit_coupon_user (user_id, status),
CONSTRAINT fk_user_benefit_coupon_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_benefit_coupon_order FOREIGN KEY (order_id) REFERENCES user_order(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='好客权益券';
DROP TABLE IF EXISTS user_redeem_record;
CREATE TABLE user_redeem_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
redeem_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
coupon_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL,
settle_amount DECIMAL(10,2) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_redeem_record_no (redeem_no),
KEY idx_user_redeem_record_store (store_id, created_at),
CONSTRAINT fk_user_redeem_record_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_redeem_record_coupon FOREIGN KEY (coupon_id) REFERENCES user_benefit_coupon(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_redeem_record_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='核销记录';
DROP TABLE IF EXISTS user_store_rating;
CREATE TABLE user_store_rating (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
redeem_record_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
service_score TINYINT NOT NULL,
env_score TINYINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_user_store_rating_redeem (redeem_record_id),
CONSTRAINT fk_user_store_rating_redeem FOREIGN KEY (redeem_record_id) REFERENCES user_redeem_record(id) ON DELETE CASCADE,
CONSTRAINT fk_user_store_rating_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='核销评价';
DROP TABLE IF EXISTS store_payout;
CREATE TABLE store_payout (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
redeem_record_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
redeem_amount DECIMAL(10,2) NOT NULL,
payout_amount DECIMAL(10,2) NOT NULL,
settlement_rate DECIMAL(5,4) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
expected_pay_at DATETIME(3) NOT NULL,
paid_at DATETIME(3) DEFAULT NULL,
batch_no VARCHAR(32) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_store_payout_redeem (redeem_record_id),
KEY idx_store_payout_store_status (store_id, status),
CONSTRAINT fk_store_payout_redeem FOREIGN KEY (redeem_record_id) REFERENCES user_redeem_record(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_payout_store FOREIGN KEY (store_id) REFERENCES store_store(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店T+1打款';
-- ===================== LOG =============================
DROP TABLE IF EXISTS log_third_party;
CREATE TABLE log_third_party (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
provider VARCHAR(32) NOT NULL COMMENT 'WECHAT_PAY|WECHAT_REFUND|WECHAT_AUTH|WECHAT_MAP|XFX|SMS|LOGISTICS',
scene VARCHAR(64) NOT NULL COMMENT '业务场景',
ref_type VARCHAR(32) DEFAULT NULL COMMENT 'ORDER|USER|STORE|...',
ref_id BIGINT UNSIGNED DEFAULT NULL,
request_url VARCHAR(512) DEFAULT NULL,
request_body JSON DEFAULT NULL COMMENT '发送参数',
response_body JSON DEFAULT NULL COMMENT '响应数据',
external_no VARCHAR(128) DEFAULT NULL COMMENT '第三方单号',
amount DECIMAL(10,2) DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING|SUCCESS|FAILED',
error_message VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_log_third_party_ref (ref_type, ref_id),
KEY idx_log_third_party_provider_scene (provider, scene, created_at),
KEY idx_log_third_party_external (external_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='第三方交互记录';
DROP TABLE IF EXISTS log_user_analytics;
CREATE TABLE log_user_analytics (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '未登录可为NULL',
session_id VARCHAR(64) DEFAULT NULL COMMENT '会话ID',
event_name VARCHAR(64) NOT NULL COMMENT '见§4.1 event_name 清单',
client_app VARCHAR(32) DEFAULT NULL COMMENT 'USER_MINI|PARTNER_MINI|HQ_MINI|SHOP_H5',
page_path VARCHAR(128) DEFAULT NULL COMMENT '页面路径',
ref_type VARCHAR(32) DEFAULT NULL COMMENT 'PRODUCT|STORE|ORDER|TAB|ADDRESS|PROMO|...',
ref_id BIGINT UNSIGNED DEFAULT NULL,
keyword VARCHAR(128) DEFAULT NULL COMMENT '搜索关键词',
source_type VARCHAR(32) DEFAULT NULL COMMENT 'register事件:来源类型快照',
source_ref_id BIGINT UNSIGNED DEFAULT NULL COMMENT 'register事件:来源ID',
extra_json JSON DEFAULT NULL COMMENT 'PRD埋点参数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_log_user_analytics_user_created (user_id, created_at),
KEY idx_log_user_analytics_event_created (event_name, created_at),
KEY idx_log_user_analytics_session (session_id),
KEY idx_log_user_analytics_ref (ref_type, ref_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为埋点日志';
DROP TABLE IF EXISTS log_store_analytics;
CREATE TABLE log_store_analytics (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
store_account_id BIGINT UNSIGNED DEFAULT NULL COMMENT '门店账号ID,系统/HQ操作可为NULL',
store_id BIGINT UNSIGNED NOT NULL COMMENT '门店ID',
event_name VARCHAR(64) NOT NULL COMMENT '门店行为事件名',
client_app VARCHAR(32) DEFAULT NULL COMMENT 'SHOP_H5|HQ_WEB|PARTNER_H5',
ref_type VARCHAR(32) DEFAULT NULL COMMENT 'REDEEM_RECORD|STORE_PAYOUT|...',
ref_id BIGINT UNSIGNED DEFAULT NULL,
extra_json JSON DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_log_store_analytics_store_created (store_id, created_at),
KEY idx_log_store_analytics_account_created (store_account_id, created_at),
KEY idx_log_store_analytics_event_created (event_name, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店商户行为日志';
SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,50 @@
-- LLM / 知识库 / 企微 AI 字段
CREATE TABLE IF NOT EXISTS `llm_api_config` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`provider` VARCHAR(32) NOT NULL,
`base_url` VARCHAR(512) NOT NULL,
`api_key` VARCHAR(512) NOT NULL,
`model_name` VARCHAR(128) NOT NULL,
`temperature` DECIMAL(3, 2) NULL,
`max_tokens` INT NULL,
`system_prompt` TEXT NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `llm_api_config_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `knowledge_base` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(128) NOT NULL,
`description` VARCHAR(512) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`created_by_hq_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `knowledge_base_created_by_hq_account_id_enabled_idx` (`created_by_hq_account_id`, `enabled`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `knowledge_document` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`knowledge_base_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(256) NOT NULL,
`file_name` VARCHAR(256) NULL,
`file_url` VARCHAR(1024) NULL,
`mime_type` VARCHAR(128) NULL,
`size_bytes` INT NULL,
`content_text` LONGTEXT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'READY',
`error_message` VARCHAR(512) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `knowledge_document_knowledge_base_id_idx` (`knowledge_base_id`),
CONSTRAINT `knowledge_document_knowledge_base_id_fkey`
FOREIGN KEY (`knowledge_base_id`) REFERENCES `knowledge_base` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,64 @@
/**
* 将 legacy common_city.partner_id 迁移至 common_city_partnerCITY_WIDE)。
* 在 prisma db push 移除 partner_id 列之前运行;若列已不存在则跳过数据拷贝。
*
* 用法:cd server/dukang-api && pnpm prisma:migrate-city-partner
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
table,
column,
);
return Number(rows[0]?.cnt ?? 0) > 0;
}
async function main() {
const hasPartnerId = await columnExists('common_city', 'partner_id');
if (!hasPartnerId) {
console.log('[migrate-city-partner] common_city.partner_id 已移除,跳过迁移');
return;
}
const cities = await prisma.$queryRawUnsafe<
Array<{ id: bigint; partner_id: bigint | null }>
>(`SELECT id, partner_id FROM common_city WHERE partner_id IS NOT NULL`);
let migrated = 0;
for (const row of cities) {
const cityId = row.id;
const partnerId = row.partner_id!;
const existing = await prisma.cityPartner.findFirst({
where: { cityId, partnerId },
});
if (existing) continue;
const rule = await prisma.commonCityCommissionRule.findUnique({ where: { cityId } });
await prisma.cityPartner.create({
data: {
cityId,
partnerId,
scopeType: 'CITY_WIDE',
orderCommissionRate: rule ? Number((rule as { orderCommissionRate?: unknown }).orderCommissionRate ?? 0) : 0,
redeemCommissionRate: rule ? Number((rule as { redeemCommissionRate?: unknown }).redeemCommissionRate ?? 0.03) : 0.03,
status: 'ACTIVE',
},
});
migrated += 1;
}
console.log(`[migrate-city-partner] 已迁移 ${migrated} 条 CITY_WIDE 绑定`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,32 @@
/**
* Partner architecture unification migration stub.
*
* Schema change: Partner / CityPartner / CommonCityCommissionRule removed;
* PartnerAccount is the primary entity; Store.partnerAccountId;
* Order.partnerAccountIdAtPay.
*
* This is a breaking schema change — there is no incremental SQL migration path
* from the legacy v3.1 tables. Apply on a fresh or disposable database:
*
* cd server/dukang-api
* npx prisma db push --force-reset
* pnpm prisma:seed
*
* Do NOT run --force-reset against production.
*/
async function main() {
console.log(`
Partner account unification requires a full schema reset.
Run:
cd server/dukang-api
npx prisma db push --force-reset
pnpm prisma:seed
`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,28 @@
-- 推广码表扩展:scene / qrcode_id / owner_user_id / remark / updated_at
-- 已有数据会先回填 qrcode_id 再设 NOT NULL
ALTER TABLE common_promo_code
ADD COLUMN IF NOT EXISTS scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name,
ADD COLUMN IF NOT EXISTS qrcode_id VARCHAR(64) NULL AFTER scene,
ADD COLUMN IF NOT EXISTS owner_user_id BIGINT UNSIGNED NULL AFTER status,
ADD COLUMN IF NOT EXISTS remark VARCHAR(256) NULL AFTER owner_user_id,
ADD COLUMN IF NOT EXISTS updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at;
UPDATE common_promo_code
SET qrcode_id = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
WHERE code = 'DKHQ001' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678'
WHERE code = 'DKDEMO1' AND (qrcode_id IS NULL OR qrcode_id = '');
UPDATE common_promo_code
SET qrcode_id = LOWER(REPLACE(UUID(), '-', ''))
WHERE qrcode_id IS NULL OR qrcode_id = '';
UPDATE common_promo_code
SET updated_at = created_at
WHERE updated_at IS NULL;
ALTER TABLE common_promo_code
MODIFY qrcode_id VARCHAR(64) NOT NULL;
@@ -0,0 +1,106 @@
/**
* 一次性迁移:common_promo_code 扩展字段(scene / qrcode_id / owner_user_id / remark / updated_at
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/migrate-promo-code-v31.ts
*/
import { PrismaClient } from '@prisma/client';
import { randomBytes } from 'crypto';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ Field: string }>>(
`SHOW COLUMNS FROM ${table} LIKE '${column}'`,
);
return rows.length > 0;
}
async function addColumn(sql: string) {
try {
await prisma.$executeRawUnsafe(sql);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate column')) throw e;
}
}
async function main() {
if (!(await columnExists('common_promo_code', 'scene'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name`,
);
}
if (!(await columnExists('common_promo_code', 'qrcode_id'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN qrcode_id VARCHAR(64) NULL AFTER scene`);
}
if (!(await columnExists('common_promo_code', 'owner_user_id'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN owner_user_id BIGINT UNSIGNED NULL AFTER status`,
);
}
if (!(await columnExists('common_promo_code', 'remark'))) {
await addColumn(`ALTER TABLE common_promo_code ADD COLUMN remark VARCHAR(256) NULL AFTER owner_user_id`);
}
if (!(await columnExists('common_promo_code', 'updated_at'))) {
await addColumn(
`ALTER TABLE common_promo_code ADD COLUMN updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at`,
);
}
const rows = await prisma.$queryRawUnsafe<Array<{ id: bigint; code: string; qrcode_id: string | null }>>(
`SELECT id, code, qrcode_id FROM common_promo_code`,
);
const preset: Record<string, string> = {
DKHQ001: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
DKDEMO1: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
};
for (const row of rows) {
if (row.qrcode_id) continue;
const qrcodeId = preset[row.code] ?? randomBytes(32).toString('hex');
await prisma.$executeRawUnsafe(
`UPDATE common_promo_code SET qrcode_id = ? WHERE id = ?`,
qrcodeId,
row.id,
);
}
await prisma.$executeRawUnsafe(`UPDATE common_promo_code SET updated_at = created_at WHERE updated_at IS NULL`);
await prisma.$executeRawUnsafe(`ALTER TABLE common_promo_code MODIFY qrcode_id VARCHAR(64) NOT NULL`);
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_owner (owner_user_id)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_scene_status (scene, status)`,
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!msg.includes('Duplicate key name')) throw e;
}
console.log('migrate-promo-code-v31: OK');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,91 @@
/**
* 发版前一次性迁移:在 prisma db push 删除 task_dispatch_* 列之前执行。
* 创建 wecom_message_push 表(若不存在)并写入默认推送。
*/
import { PrismaClient } from '@prisma/client';
import {
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
} from '@dukang/shared-types';
const prisma = new PrismaClient();
async function ensureTable() {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS wecom_message_push (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
avatar_url VARCHAR(512) NULL,
webhook_url VARCHAR(512) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
mention_wecom_user_id VARCHAR(64) NULL,
push_conditions TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX wecom_message_push_enabled_sort_order_idx (enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function main() {
await ensureTable();
const count = await prisma.wecomMessagePush.count();
if (count > 0) {
console.log(`wecom_message_push already has ${count} row(s), skip`);
return;
}
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
await prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: process.env.WECOM_ALERT_ENABLED !== 'false',
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
console.log('migrated: 运营告警');
}
try {
const rows = await prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row?.task_dispatch_webhook_url?.trim()) {
await prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: row.task_dispatch_webhook_url.trim(),
enabled: !!row.task_dispatch_enabled,
mentionWecomUserId: row.task_dispatch_wecom_user_id?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
},
});
console.log('migrated: 开发任务派发');
}
} catch (e) {
console.log('dev_plan_settings task_dispatch columns unavailable:', e instanceof Error ? e.message : e);
}
console.log('migrate-wecom-message-push done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+854
View File
@@ -0,0 +1,854 @@
// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成)
// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31
// 旧版备份:schema.legacy-v21.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ─── 枚举(与 init_v3.sql COMMENT 对齐)────────────────
enum ClientApp {
USER_MINI
USER_H5
PARTNER_MINI
PARTNER_H5
HQ_MINI
HQ_WEB
SHOP_H5
}
enum ResourceOwnerType {
PRODUCT
STORE
PARTNER
USER
ORDER
PROMO
HQ
}
enum ResourceBizType {
COVER
ENV
CONTRACT
CAROUSEL
DETAIL
AVATAR
QRCODE
SIGN_PHOTO
VIDEO
}
enum ResourceMediaType {
IMAGE
VIDEO
FILE
}
enum ResourceStatus {
ACTIVE
DELETED
}
enum EventType {
STORE_AUDIT
ORDER_STATUS
BENEFIT_LEDGER
HQ_OPERATION
PROMO_TOUCH
TICKET_COLLAB
}
enum ActorType {
USER
STORE
PARTNER
HQ
SYSTEM
}
enum TicketType {
REFUND
RESHIPMENT
ALERT
}
enum AromaType {
QINGXIANG
JIANGXIANG
NONGXIANG
}
enum ProductStatus {
DRAFT
ON_SALE
OFF_SALE
}
enum PromoCodeStatus {
ACTIVE
DISABLED
}
enum CityStatus {
PENDING
ACTIVE
PAUSED
}
enum PartnerStaffRole {
PARTNER
INTERNAL
PROMOTER
}
enum AccountStatus {
ACTIVE
DISABLED
}
enum HqAdminRole {
SUPER_ADMIN
OPS
FINANCE
CUSTOMER_SERVICE
}
enum PartnerBillStatus {
DRAFT
CONFIRMED
PAID
}
enum StoreStatus {
OPEN
PAUSED
CLOSED
}
enum UserSourceType {
ORGANIC
PROMO_CODE
SHARE_LINK
FRIEND_REFERRAL
OFFLINE_EVENT
OTHER
}
enum OrderType {
NORMAL
RESHIPMENT
}
enum OrderStatus {
PENDING_PAY
PENDING_SHIP
OUT_WAREHOUSE
SHIPPING
PENDING_RECEIVE
COMPLETED
CANCELLED
REFUNDING
REFUNDED
}
enum PayStatus {
UNPAID
PAYING
PAID
REFUNDING
REFUNDED
}
enum DeliveryType {
LOCAL
CROSS_CITY
}
enum FreightPayType {
FREE
COD
}
enum DeliveryProvider {
XFX
LOGISTICS
MANUAL
}
enum BenefitCouponStatus {
ACTIVE
USED_UP
VOID
}
enum StorePayoutStatus {
PENDING
PAID
}
enum ThirdPartyProvider {
WECHAT_PAY
WECHAT_REFUND
WECHAT_AUTH
WECHAT_MAP
XFX
SMS
LOGISTICS
}
enum ThirdPartyLogStatus {
PENDING
SUCCESS
FAILED
}
enum BenefitLedgerType {
GRANT
REDEEM
REFUND_VOID
ADJUST
}
// ─── COMMON ───────────────────────────────────────────
model CommonWxAppConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
clientApp ClientApp @unique @map("client_app")
appId String @map("app_id") @db.VarChar(64)
appSecret String @map("app_secret") @db.VarChar(128)
mchId String? @map("mch_id") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("common_wx_app_config")
}
model CommonResource {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ownerType ResourceOwnerType @map("owner_type")
ownerId BigInt @map("owner_id") @db.UnsignedBigInt
bizType ResourceBizType @map("biz_type")
mediaType ResourceMediaType @default(IMAGE) @map("media_type")
ossBucket String @map("oss_bucket") @db.VarChar(64)
ossKey String @map("oss_key") @db.VarChar(256)
url String @db.VarChar(512)
fileName String? @map("file_name") @db.VarChar(128)
fileSize BigInt? @map("file_size") @db.UnsignedBigInt
mimeType String? @map("mime_type") @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
status ResourceStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
productCovers CommonProductItem[] @relation("ProductCover")
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
userAvatars User[] @relation("UserAvatar")
storeCovers Store[] @relation("StoreCover")
orderImages Order[] @relation("OrderProductImage")
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
@@index([ownerType, ownerId, bizType])
@@index([status])
@@map("common_resource")
}
model CommonEvent {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
eventType EventType @map("event_type")
refType String @map("ref_type") @db.VarChar(32)
refId BigInt @map("ref_id") @db.UnsignedBigInt
actorType ActorType? @map("actor_type")
actorId BigInt? @map("actor_id") @db.UnsignedBigInt
status String? @db.VarChar(32)
param1 String? @db.VarChar(128)
param1Desc String? @map("param1_desc") @db.VarChar(64)
param2 String? @db.VarChar(128)
param2Desc String? @map("param2_desc") @db.VarChar(64)
param3 String? @db.VarChar(128)
param3Desc String? @map("param3_desc") @db.VarChar(64)
amount1 Decimal? @db.Decimal(10, 2)
amount2 Decimal? @db.Decimal(10, 2)
remark String? @db.VarChar(512)
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([refType, refId, eventType])
@@index([eventType, createdAt])
@@index([actorType, actorId])
@@map("common_event")
}
model CommonTicket {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
ticketType TicketType @map("ticket_type")
status String @default("PENDING") @db.VarChar(32)
refType String @map("ref_type") @db.VarChar(32)
refId BigInt @map("ref_id") @db.UnsignedBigInt
operatorType ActorType? @map("operator_type")
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
param1 String? @db.VarChar(128)
param1Desc String? @map("param1_desc") @db.VarChar(64)
param2 String? @db.VarChar(128)
param2Desc String? @map("param2_desc") @db.VarChar(64)
param3 String? @db.VarChar(128)
param3Desc String? @map("param3_desc") @db.VarChar(64)
remark String? @db.VarChar(512)
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@index([refType, refId])
@@index([ticketType, status])
@@map("common_ticket")
}
model CommonProductItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
name String @db.VarChar(128)
subtitle String? @db.VarChar(256)
aromaType AromaType @map("aroma_type")
spec String @db.VarChar(128)
price Decimal @db.Decimal(10, 2)
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
detailContent Json? @map("detail_content")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
@@index([status, aromaType])
@@map("common_product_item")
}
model CommonStoreCategory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(64)
sort Int @default(0)
parentId BigInt? @map("parent_id") @db.UnsignedBigInt
status String @default("ACTIVE") @db.VarChar(16)
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children CommonStoreCategory[] @relation("StoreCategoryTree")
stores Store[]
@@index([parentId, sort])
@@index([status])
@@map("common_store_category")
}
model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
status PromoCodeStatus @default(ACTIVE)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
@@map("common_promo_code")
}
model CommonCity {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(16)
name String @db.VarChar(64)
province String @db.VarChar(32)
status CityStatus @default(PENDING)
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_min_qty")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
commissionRule CommonCityCommissionRule?
stores Store[]
orders Order[]
@@index([partnerId])
@@map("common_city")
}
model CommonCityCommissionRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
@@map("common_city_commission_rule")
}
// ─── PARTNER ──────────────────────────────────────────
model Partner {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
companyName String @map("company_name") @db.VarChar(128)
address String @db.VarChar(256)
contactPhone String @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cities CommonCity[]
accounts PartnerAccount[]
stores Store[]
bills PartnerBill[]
@@index([contactPhone])
@@map("partner_partner")
}
model PartnerAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
@@index([partnerId])
@@index([parentAccountId])
@@index([wxOpenId])
@@map("partner_account")
}
model PartnerBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
@@index([partnerId, status])
@@map("partner_bill")
}
// ─── HQ ───────────────────────────────────────────────
model HqAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
adminRole HqAdminRole @default(OPS) @map("admin_role")
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("hq_account")
}
// ─── USER ─────────────────────────────────────────────
model User {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userNo String @unique @map("user_no") @db.VarChar(20)
deviceKey String? @unique @map("device_key") @db.VarChar(36)
phone String? @unique @db.VarChar(20)
phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
nickname String? @db.VarChar(64)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt
sourceType UserSourceType @default(ORGANIC) @map("source_type")
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
sourceLabel String? @map("source_label") @db.VarChar(128)
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
mergedFrom User[] @relation("UserMerge")
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
referrers User[] @relation("UserReferrer")
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
addresses UserAddress[]
cityPreference UserCityPreference?
promoTouch UserPromoAttribution?
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
@@index([sourceType, sourceRefId])
@@index([referrerUserId])
@@index([mergedIntoUserId])
@@index([wxOpenId])
@@map("user_user")
}
model UserAddress {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
receiverName String @map("receiver_name") @db.VarChar(32)
phone String @db.VarChar(20)
province String @db.VarChar(32)
city String @db.VarChar(32)
district String @db.VarChar(32)
detail String @db.VarChar(256)
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
isDefault Int @default(0) @map("is_default") @db.TinyInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_address")
}
model UserCityPreference {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
selectedDistrict String? @map("selected_district") @db.VarChar(32)
locateCityCode String? @map("locate_city_code") @db.VarChar(16)
locateDistrict String? @map("locate_district") @db.VarChar(32)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_city_preference")
}
model UserPromoAttribution {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
channelName String @map("channel_name") @db.VarChar(128)
firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
@@index([promoCodeId])
@@map("user_promo_attribution")
}
// ─── STORE ────────────────────────────────────────────
model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
phone String @db.VarChar(20)
province String @db.VarChar(32)
cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
address String @db.VarChar(256)
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
intro String? @db.Text
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
rating Decimal? @db.Decimal(3, 2)
tags Json?
status StoreStatus @default(PAUSED)
openTime String? @map("open_time") @db.VarChar(8)
closeTime String? @map("close_time") @db.VarChar(8)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
redeemRecords RedeemRecord[]
ratings StoreRating[]
payouts StorePayout[]
@@index([cityId, status])
@@index([partnerId])
@@map("store_store")
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@map("store_account")
}
// ─── ORDER ────────────────────────────────────────────
model Order {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderNo String @unique @map("order_no") @db.VarChar(32)
orderType OrderType @default(NORMAL) @map("order_type")
userId BigInt @map("user_id") @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
status OrderStatus @default(PENDING_PAY)
payStatus PayStatus @default(UNPAID) @map("pay_status")
deliveryType DeliveryType @map("delivery_type")
originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
channelSource String? @map("channel_source") @db.VarChar(128)
productId BigInt @map("product_id") @db.UnsignedBigInt
barcode69 String @map("barcode_69") @db.VarChar(32)
productName String @map("product_name") @db.VarChar(128)
productSpec String @map("product_spec") @db.VarChar(128)
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
quantity Int
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
freightPayType FreightPayType? @map("freight_pay_type")
payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
receiverName String @map("receiver_name") @db.VarChar(32)
receiverPhone String @map("receiver_phone") @db.VarChar(20)
receiverAddress String @map("receiver_address") @db.Text
receiverProvince String @map("receiver_province") @db.VarChar(32)
receiverCity String @map("receiver_city") @db.VarChar(32)
receiverDistrict String @map("receiver_district") @db.VarChar(32)
clientIp String? @map("client_ip") @db.VarChar(45)
ipProvince String? @map("ip_province") @db.VarChar(32)
ipCity String? @map("ip_city") @db.VarChar(32)
ipDistrict String? @map("ip_district") @db.VarChar(32)
gpsProvince String? @map("gps_province") @db.VarChar(32)
gpsCity String? @map("gps_city") @db.VarChar(32)
gpsDistrict String? @map("gps_district") @db.VarChar(32)
gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
gpsAddress String? @map("gps_address") @db.VarChar(256)
payExternalNo String? @map("pay_external_no") @db.VarChar(64)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
delivery OrderDelivery?
benefitCoupon BenefitCoupon?
@@index([userId, status])
@@index([cityId, createdAt])
@@index([productId])
@@index([barcode69])
@@index([payExternalNo])
@@index([ipCity])
@@index([gpsCity])
@@map("user_order")
}
model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
trackingNo String? @map("tracking_no") @db.VarChar(64)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
@@map("user_order_delivery")
}
// ─── BENEFIT & REDEEM ─────────────────────────────────
model BenefitCoupon {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
couponNo String @unique @map("coupon_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2)
status BenefitCouponStatus @default(ACTIVE)
sourceProduct String @map("source_product") @db.VarChar(128)
version Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
redeemRecords RedeemRecord[]
@@index([userId, status])
@@map("user_benefit_coupon")
}
model RedeemRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
rating StoreRating?
payout StorePayout?
@@index([storeId, createdAt])
@@map("user_redeem_record")
}
model StoreRating {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
serviceScore Int @map("service_score") @db.TinyInt
envScore Int @map("env_score") @db.TinyInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
@@map("user_store_rating")
}
model StorePayout {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
status StorePayoutStatus @default(PENDING)
expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
batchNo String? @map("batch_no") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
@@index([storeId, status])
@@map("store_payout")
}
// ─── LOG ──────────────────────────────────────────────
model LogThirdParty {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
provider ThirdPartyProvider
scene String @db.VarChar(64)
refType String? @map("ref_type") @db.VarChar(32)
refId BigInt? @map("ref_id") @db.UnsignedBigInt
requestUrl String? @map("request_url") @db.VarChar(512)
requestBody Json? @map("request_body")
responseBody Json? @map("response_body")
externalNo String? @map("external_no") @db.VarChar(128)
amount Decimal? @db.Decimal(10, 2)
status ThirdPartyLogStatus @default(PENDING)
errorMessage String? @map("error_message") @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([refType, refId])
@@index([provider, scene, createdAt])
@@index([externalNo])
@@map("log_third_party")
}
model LogUserAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt? @map("user_id") @db.UnsignedBigInt
sessionId String? @map("session_id") @db.VarChar(64)
eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app")
pagePath String? @map("page_path") @db.VarChar(128)
refType String? @map("ref_type") @db.VarChar(32)
refId BigInt? @map("ref_id") @db.UnsignedBigInt
keyword String? @db.VarChar(128)
sourceType String? @map("source_type") @db.VarChar(32)
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([userId, createdAt])
@@index([eventName, createdAt])
@@index([sessionId])
@@index([refType, refId])
@@map("log_user_analytics")
}
@@ -0,0 +1,447 @@
/**
* 财务四账单 Mock 数据(不重跑全量 seed)
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/seed-finance-mock.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function billNo(prefix: string, suffix: string) {
return `${prefix}MOCK${suffix}`.slice(0, 32);
}
async function main() {
console.log('Seeding finance mock bills...');
const store = await prisma.store.findFirst({
where: { auditStatus: 'APPROVED' },
orderBy: { id: 'asc' },
});
if (!store) throw new Error('没有门店数据,请先 pnpm db:seed');
const partner = await prisma.partnerAccount.findFirst({
where: { isPrimary: 1, status: 'ACTIVE' },
orderBy: { id: 'asc' },
});
if (!partner) throw new Error('没有合伙人主账号,请先 pnpm db:seed');
let provider = await prisma.fulfillmentProvider.findFirst({
where: { code: { in: ['XFX', 'XIAOFEIXIA'] } },
});
if (!provider) {
provider = await prisma.fulfillmentProvider.findFirst({ orderBy: { id: 'asc' } });
}
if (!provider) throw new Error('没有仓配承运商,请先 pnpm db:seed 或在仓配管理注册');
// 确保小飞侠有对账配置,便于物流汇总页展示
if (!provider.pricingRulesJson || !provider.bankAccountName) {
provider = await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: {
settlementMethod: provider.settlementMethod ?? 'PREPAID',
prepaidBalance: Number(provider.prepaidBalance) > 0 ? provider.prepaidBalance : 5000,
bankAccountName: provider.bankAccountName || '郑州小飞侠物流有限公司',
bankName: provider.bankName || '招商银行',
bankBranch: provider.bankBranch || '郑州金水支行',
bankAccountNo: provider.bankAccountNo || '6225880000001234',
pricingRulesJson:
provider.pricingRulesJson ||
JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
},
});
}
const today = startOfDay(new Date());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dayBefore = new Date(today);
dayBefore.setDate(dayBefore.getDate() - 2);
const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
const prevMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const prevMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0, 23, 59, 59, 999);
// 清理旧 mock(按账单号前缀)
await prisma.logisticsBillItem.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsPrepaidLedger.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsBill.deleteMany({ where: { billNo: { startsWith: 'LBMOCK' } } });
await prisma.wineryBillItem.deleteMany({
where: { wineryBill: { billNo: { startsWith: 'WBMOCK' } } },
});
await prisma.wineryBill.deleteMany({ where: { billNo: { startsWith: 'WBMOCK' } } });
await prisma.storePayout.updateMany({
where: { storeBill: { billNo: { startsWith: 'SBMOCK' } } },
data: { storeBillId: null },
});
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(昨日未打款 + 前日已打款)───
const storeBillUnpaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id,
billDate: yesterday,
redeemCount: 5,
redeemAmount: 1500,
settlementRate: 0.6,
payoutAmount: 900,
status: 'UNPAID',
},
});
const storeBillPaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id,
billDate: dayBefore,
redeemCount: 3,
redeemAmount: 800,
settlementRate: 0.6,
payoutAmount: 480,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 2. 合伙人月账单(上月待确认 + 本月已打款样例)───
const partnerBillAwait = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
partnerAccountId: partner.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
},
update: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
paidAt: null,
rejectReason: null,
},
});
// 本月另起一条若冲突则跳过(unique partner+period)— 用上上月做已打款
const olderMonthStart = new Date(today.getFullYear(), today.getMonth() - 2, 1);
const olderMonthEnd = new Date(today.getFullYear(), today.getMonth() - 1, 0, 23, 59, 59, 999);
const partnerBillPaid = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: olderMonthStart,
},
},
create: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
partnerAccountId: partner.id,
periodStart: olderMonthStart,
periodEnd: olderMonthEnd,
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
sentAt: olderMonthEnd,
confirmedAt: thisMonthStart,
paidAt: thisMonthStart,
},
update: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
paidAt: thisMonthStart,
},
});
// ─── 3. 酒厂日账单 ───
const wineryUnpaid = await prisma.wineryBill.upsert({
where: { billDate: yesterday },
create: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
billDate: yesterday,
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
},
update: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
paidAt: null,
},
});
await prisma.wineryBillItem.deleteMany({ where: { wineryBillId: wineryUnpaid.id } });
// 用极大假 orderId,避免与真实订单冲突
const fakeOrderBase = BigInt('900000000000000001');
await prisma.wineryBillItem.createMany({
data: [
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase,
orderNo: 'DKMOCK-WB-001',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 1n,
orderNo: 'DKMOCK-WB-002',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 2n,
orderNo: 'DKMOCK-WB-003',
deliveryType: 'CROSS_CITY',
payAmount: 1797,
wineryAmount: 539.1,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 3n,
orderNo: 'DKMOCK-WB-004',
deliveryType: 'LOCAL',
payAmount: 599,
wineryAmount: 179.7,
paidAt: yesterday,
},
],
});
const wineryPaid = await prisma.wineryBill.upsert({
where: { billDate: dayBefore },
create: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
billDate: dayBefore,
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 4. 物流对账月账单(上月挂账未结 + 本月充值已扣)───
const logisticsUnpaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'UNPAID',
},
update: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
status: 'UNPAID',
paidAt: null,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsUnpaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 10n,
orderNo: 'DKMOCK-LB-001',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 3 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 11n,
orderNo: 'DKMOCK-LB-002',
quantity: 8,
logisticsAmount: 20, // 箱14 + 2瓶6
shippedAt: new Date(prevMonthStart.getTime() + 8 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 12n,
orderNo: 'DKMOCK-LB-003',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 15 * 86400000),
},
],
});
// 修正合计:14+20+14=48
await prisma.logisticsBill.update({
where: { id: logisticsUnpaid.id },
data: { bottleCount: 20, logisticsAmount: 48 },
});
const logisticsPaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
},
},
create: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
periodEnd: thisMonthEnd,
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
status: 'PAID',
paidAt: yesterday,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsPaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 20n,
orderNo: 'DKMOCK-LB-101',
quantity: 4,
logisticsAmount: 10,
shippedAt: new Date(thisMonthStart.getTime() + 2 * 86400000),
},
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 21n,
orderNo: 'DKMOCK-LB-102',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(thisMonthStart.getTime() + 5 * 86400000),
},
],
});
// 充值流水样例
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'RECHARGE',
amount: 5000,
balanceAfter: 5000,
remark: 'Mock 预充运费',
},
});
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'DEDUCT',
amount: 22,
balanceAfter: 4978,
logisticsBillId: logisticsPaid.id,
remark: 'Mock 本月物流账单扣款',
},
});
await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: { prepaidBalance: 4978, settlementMethod: 'PREPAID' },
});
console.log('Finance mock ready:', {
store: { id: store.id.toString(), name: store.name },
partner: { id: partner.id.toString(), phone: partner.phone },
provider: { id: provider.id.toString(), code: provider.code, name: provider.name },
storeBills: [storeBillUnpaid.billNo, storeBillPaid.billNo],
partnerBills: [partnerBillAwait.billNo, partnerBillPaid.billNo],
wineryBills: [wineryUnpaid.billNo, wineryPaid.billNo],
logisticsBills: [logisticsUnpaid.billNo, logisticsPaid.billNo],
});
console.log('打开 admin:财务 → 门店/合伙人/酒厂/物流对账');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+245
View File
@@ -0,0 +1,245 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding preV1 data...');
await prisma.partnerBill.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitLedger.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderStatusLog.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.payment.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.storeAudit.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.storeMedia.deleteMany();
await prisma.store.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.cityCommissionRule.deleteMany();
await prisma.city.deleteMany();
await prisma.partner.deleteMany();
await prisma.product.deleteMany();
await prisma.storeCategory.deleteMany();
await prisma.hqAccount.deleteMany();
const partner = await prisma.partner.create({
data: {
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
},
});
const city = await prisma.city.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2,
crossMinQty: 6,
},
});
await prisma.cityCommissionRule.create({
data: {
cityId: city.id,
orderCommissionRate: 0.05,
redeemCommissionRate: 0.03,
partnerProfitRate: 0.35,
storeSettlementRate: 0.6,
},
});
const categories = await Promise.all([
prisma.storeCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.storeCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]);
const products = await Promise.all([
prisma.product.create({
data: {
skuCode: 'QX-001',
name: '杜康·白水古酿 500ml',
subtitle: '清香型 52度 礼盒装',
aromaType: 'QINGXIANG',
spec: '500ml | 52度',
price: 599,
benefitAmount: 599,
status: 'ON_SALE',
sortOrder: 1,
mainImageUrl: 'https://picsum.photos/seed/dukang1/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-002',
name: '杜康·年份陈酿(十年)',
subtitle: '清香型 42度 纯粮酿造',
aromaType: 'QINGXIANG',
spec: '500ml | 42度',
price: 880,
benefitAmount: 880,
status: 'ON_SALE',
sortOrder: 2,
mainImageUrl: 'https://picsum.photos/seed/dukang2/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-003',
name: '杜康·御享1号 珍藏版',
subtitle: '高端定制 限量发售',
aromaType: 'QINGXIANG',
spec: '500ml | 52度',
price: 1299,
benefitAmount: 1299,
status: 'ON_SALE',
sortOrder: 3,
mainImageUrl: 'https://picsum.photos/seed/dukang3/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-004',
name: '杜康·经典传承',
subtitle: '清香型 纯粮固态',
aromaType: 'QINGXIANG',
spec: '500ml | 46度',
price: 399,
benefitAmount: 399,
status: 'ON_SALE',
sortOrder: 4,
mainImageUrl: 'https://picsum.photos/seed/dukang4/400/400',
},
}),
]);
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
},
});
const store1 = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: categories[0].id,
name: '郑州老城店',
phone: '13910000001',
province: '河南省',
cityName: '郑州市',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
coverUrl: 'https://picsum.photos/seed/store1/400/300',
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: '郑州老城店',
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
const store2 = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: categories[1].id,
name: '郑州美食城店',
phone: '13910000002',
province: '河南省',
cityName: '郑州市',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
coverUrl: 'https://picsum.photos/seed/store2/400/300',
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
},
});
await prisma.storeAccount.create({
data: { storeId: store1.id, phone: '13910000001', name: '郑州老城店' },
});
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
nickname: '测试用户',
cityPref: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerId: partner.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'UNPAID',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
products: products.length,
stores: 2,
testPhones: {
user: '13800000001',
store: '13910000001',
partner: '13700000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+489
View File
@@ -0,0 +1,489 @@
/**
* 概览页统计 Mock(用户/订单/合伙人/门店/核销,幂等可重复跑)
* 用法:cd server/dukang-api && pnpm prisma:seed-stats
*/
import { PrismaClient, type OrderStatus, type PayStatus } from '@prisma/client';
import { createHash, randomBytes } from 'crypto';
const prisma = new PrismaClient();
const USER_PREFIX = 'STAT';
const ORDER_PREFIX = 'STAT';
const COUPON_PREFIX = 'STATCPN';
const REDEEM_PREFIX = 'STATRD';
const STORE_NAME_PREFIX = 'STAT门店';
const PARTNER_PHONE_PREFIX = '13788';
const PROMO_CODES = [
{ code: 'STAT_A', name: '统计演示·品鉴会A', scene: 'EVENT' as const },
{ code: 'STAT_B', name: '统计演示·线下提货B', scene: 'OFFLINE_PICKUP' as const },
{ code: 'STAT_C', name: '统计演示·线上渠道C', scene: 'ONLINE_LINK' as const },
];
const CITY_DEFS = [
{ code: '410100', name: '郑州市', province: '河南省', district: '金水区' },
{ code: '410300', name: '洛阳市', province: '河南省', district: '涧西区' },
];
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function qrcodeIdFor(code: string) {
return createHash('sha256').update(`stats-mock:${code}`).digest('hex');
}
function mulberry32(seed: number) {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async function cleanup() {
console.log('Cleaning previous STAT* mock...');
const redeemIds = (
await prisma.redeemRecord.findMany({
where: { redeemNo: { startsWith: REDEEM_PREFIX } },
select: { id: true },
})
).map((r) => r.id);
if (redeemIds.length) {
await prisma.redeemRecordAllocation.deleteMany({
where: { redeemRecordId: { in: redeemIds } },
});
await prisma.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await prisma.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await prisma.redeemPendingRecord.deleteMany({
where: { redeemRecordId: { in: redeemIds } },
});
await prisma.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await prisma.benefitCoupon.deleteMany({
where: { couponNo: { startsWith: COUPON_PREFIX } },
});
const statOrders = await prisma.order.findMany({
where: { orderNo: { startsWith: ORDER_PREFIX } },
select: { id: true },
});
const orderIds = statOrders.map((o) => o.id);
if (orderIds.length) {
await prisma.benefitCoupon.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.orderDelivery.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.order.deleteMany({ where: { id: { in: orderIds } } });
}
const statUsers = await prisma.user.findMany({
where: { userNo: { startsWith: USER_PREFIX } },
select: { id: true },
});
const userIds = statUsers.map((u) => u.id);
if (userIds.length) {
await prisma.userPromoAttribution.deleteMany({ where: { userId: { in: userIds } } });
await prisma.userCityPreference.deleteMany({ where: { userId: { in: userIds } } });
await prisma.userAddress.deleteMany({ where: { userId: { in: userIds } } });
await prisma.benefitCoupon.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
}
await prisma.commonPromoCode.deleteMany({
where: { code: { in: PROMO_CODES.map((p) => p.code) } },
});
const statStores = await prisma.store.findMany({
where: { name: { startsWith: STORE_NAME_PREFIX } },
select: { id: true },
});
const storeIds = statStores.map((s) => s.id);
if (storeIds.length) {
await prisma.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
await prisma.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
await prisma.store.deleteMany({ where: { id: { in: storeIds } } });
}
const statPartners = await prisma.partnerAccount.findMany({
where: { phone: { startsWith: PARTNER_PHONE_PREFIX } },
select: { id: true },
});
const partnerIds = statPartners.map((p) => p.id);
if (partnerIds.length) {
await prisma.partnerBill.deleteMany({ where: { partnerAccountId: { in: partnerIds } } });
await prisma.partnerAccount.deleteMany({
where: { OR: [{ id: { in: partnerIds } }, { parentAccountId: { in: partnerIds } }] },
});
}
}
async function ensureCities() {
const cities = [];
for (const def of CITY_DEFS) {
const city = await prisma.commonCity.upsert({
where: { code: def.code },
create: {
code: def.code,
name: def.name,
province: def.province,
status: 'ACTIVE',
},
update: {
name: def.name,
province: def.province,
status: 'ACTIVE',
},
});
cities.push({ ...city, district: def.district });
}
return cities;
}
async function main() {
console.log('Seeding stats mock (users/orders/partners/stores/redeems)...');
await cleanup();
const cities = await ensureCities();
const product = await prisma.commonProductItem.findFirst({
orderBy: { id: 'asc' },
select: {
id: true,
barcode69: true,
name: true,
spec: true,
price: true,
},
});
if (!product) throw new Error('没有商品,请先 pnpm prisma:seed');
const category = await prisma.commonStoreCategory.findFirst({ orderBy: { id: 'asc' } });
const promos = [];
for (const def of PROMO_CODES) {
const promo = await prisma.commonPromoCode.create({
data: {
code: def.code,
name: def.name,
scene: def.scene,
qrcodeId: qrcodeIdFor(def.code),
status: 'ACTIVE',
scanCount: 0,
orderCount: 0,
},
});
promos.push(promo);
}
const rand = mulberry32(20260731);
const today = startOfDay(new Date());
// ── 城市合伙人 + 门店(近 30 天分散创建) ──
const createdPartners: Array<{
id: bigint;
cityId: bigint;
city: (typeof cities)[0];
}> = [];
const createdStores: Array<{
id: bigint;
cityId: bigint;
partnerAccountId: bigint;
settlementRate: number;
}> = [];
for (let i = 0; i < cities.length; i++) {
const city = cities[i];
// 每城 2 个主合伙人
for (let j = 0; j < 2; j++) {
const dayOffset = Math.floor(rand() * 28);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(9, 0, 0, 0);
const phone = `${PARTNER_PHONE_PREFIX}${String(i * 10 + j + 1).padStart(5, '0')}`;
const partner = await prisma.partnerAccount.create({
data: {
phone,
name: `${city.name}统计合伙人${j + 1}`,
companyName: `STAT${city.name}合伙人${j + 1}`,
isPrimary: 1,
status: 'ACTIVE',
cityId: city.id,
scopeType: 'CITY_WIDE',
bindingStatus: 'ACTIVE',
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
bankAccountName: `${city.name}统计合伙人${j + 1}`,
bankAccountNo: `622202${String(1000000000 + i * 10 + j)}`,
bankBranch: `${city.name}工商银行`,
createdAt,
updatedAt: createdAt,
},
});
createdPartners.push({ id: partner.id, cityId: city.id, city });
// 每位合伙人 3~5 家门店
const storeN = 3 + Math.floor(rand() * 3);
for (let k = 0; k < storeN; k++) {
const sDay = Math.floor(rand() * Math.max(1, dayOffset + 1));
const sCreated = new Date(today);
sCreated.setDate(sCreated.getDate() - sDay);
sCreated.setHours(11, Math.floor(rand() * 40), 0, 0);
const rate = 0.6;
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerAccountId: partner.id,
categoryId: category?.id,
name: `${STORE_NAME_PREFIX}-${city.name}-${j + 1}-${k + 1}`,
phone: `1399${String(100000 + i * 100 + j * 10 + k).slice(-7)}`,
province: city.province,
cityName: city.name,
district: city.district,
address: `统计路${k + 1}`,
settlementRate: rate,
status: 'OPEN',
auditStatus: 'APPROVED',
openTime: '10:00',
closeTime: '22:00',
createdAt: sCreated,
updatedAt: sCreated,
},
});
createdStores.push({
id: store.id,
cityId: city.id,
partnerAccountId: partner.id,
settlementRate: rate,
});
}
}
}
// ── 用户 + 订单 ──
const userCount = 100;
const createdUsers: Array<{
id: bigint;
cityId: bigint;
city: (typeof cities)[0];
promoId: bigint | null;
phone: string;
}> = [];
const paidOrders: Array<{
id: bigint;
userId: bigint;
cityId: bigint;
payAmount: number;
productName: string;
}> = [];
for (let i = 0; i < userCount; i++) {
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(8 + Math.floor(rand() * 12), Math.floor(rand() * 60), 0, 0);
const city = cities[Math.floor(rand() * cities.length)];
const promo = rand() < 0.7 ? promos[Math.floor(rand() * promos.length)] : null;
const seq = String(i + 1).padStart(4, '0');
const phone = `13888${String(10000 + i).slice(-5)}`;
const user = await prisma.user.create({
data: {
userNo: `${USER_PREFIX}${seq}`,
phone,
phoneVerifiedAt: createdAt,
nickname: `统计用户${seq}`,
deviceKey: `stat-device-${seq}-${randomBytes(4).toString('hex')}`,
sourceType: promo ? 'PROMO_CODE' : 'ORGANIC',
sourceRefId: promo?.id ?? null,
sourceLabel: promo?.name ?? null,
createdAt,
updatedAt: createdAt,
cityPreference: {
create: {
selectedCityCode: city.code,
selectedDistrict: city.district,
locateCityCode: city.code,
locateDistrict: city.district,
updatedAt: createdAt,
},
},
...(promo
? {
promoTouch: {
create: {
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: createdAt,
},
},
}
: {}),
},
});
createdUsers.push({
id: user.id,
cityId: city.id,
city,
promoId: promo?.id ?? null,
phone,
});
}
const statuses: Array<{ status: OrderStatus; payStatus: PayStatus }> = [
{ status: 'COMPLETED', payStatus: 'PAID' },
{ status: 'COMPLETED', payStatus: 'PAID' },
{ status: 'PENDING_SHIP', payStatus: 'PAID' },
{ status: 'SHIPPING', payStatus: 'PAID' },
{ status: 'PENDING_PAY', payStatus: 'UNPAID' },
{ status: 'CANCELLED', payStatus: 'UNPAID' },
];
let orderCount = 0;
const promoOrderInc = new Map<string, number>();
for (const u of createdUsers) {
const n = 1 + Math.floor(rand() * 3);
for (let j = 0; j < n; j++) {
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(10 + Math.floor(rand() * 10), Math.floor(rand() * 60), 0, 0);
const qty = 2 + Math.floor(rand() * 3);
const unit = Number(product.price);
const listAmount = unit * qty;
const st = statuses[Math.floor(rand() * statuses.length)];
const paid = st.payStatus === 'PAID';
orderCount += 1;
const orderNo = `${ORDER_PREFIX}${String(orderCount).padStart(6, '0')}`;
const order = await prisma.order.create({
data: {
orderNo,
userId: u.id,
cityId: u.cityId,
promoCodeId: u.promoId,
channelSource: u.promoId ? 'STATS_MOCK' : null,
status: st.status,
payStatus: st.payStatus,
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
quantity: qty,
listUnitPrice: unit,
listAmount,
productAmount: listAmount,
payAmount: listAmount,
benefitAmount: listAmount,
receiverName: `统计用户`,
receiverPhone: u.phone,
receiverAddress: `${u.city.name}${u.city.district}统计路1号`,
receiverProvince: u.city.province,
receiverCity: u.city.name,
receiverDistrict: u.city.district,
paidAt: paid ? createdAt : null,
completedAt: st.status === 'COMPLETED' ? createdAt : null,
cancelledAt: st.status === 'CANCELLED' ? createdAt : null,
createdAt,
updatedAt: createdAt,
},
});
if (paid) {
paidOrders.push({
id: order.id,
userId: u.id,
cityId: u.cityId,
payAmount: listAmount,
productName: product.name,
});
}
if (u.promoId) {
const key = u.promoId.toString();
promoOrderInc.set(key, (promoOrderInc.get(key) ?? 0) + 1);
}
}
}
for (const [id, inc] of promoOrderInc) {
await prisma.commonPromoCode.update({
where: { id: BigInt(id) },
data: { orderCount: { increment: inc }, scanCount: { increment: Math.floor(inc * 1.5) } },
});
}
// ── 权益券 + 核销(约 80 笔,分散近 30 天) ──
const storesByCity = new Map<string, typeof createdStores>();
for (const s of createdStores) {
const key = s.cityId.toString();
const list = storesByCity.get(key) ?? [];
list.push(s);
storesByCity.set(key, list);
}
let redeemCount = 0;
const redeemTarget = Math.min(80, paidOrders.length);
for (let i = 0; i < redeemTarget; i++) {
const order = paidOrders[i];
const cityStores = storesByCity.get(order.cityId.toString()) ?? createdStores;
if (!cityStores.length) continue;
const store = cityStores[Math.floor(rand() * cityStores.length)];
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(14 + Math.floor(rand() * 6), Math.floor(rand() * 60), 0, 0);
const amount = Math.round((40 + rand() * 200) * 100) / 100;
const settleAmount = Math.round(amount * store.settlementRate * 100) / 100;
const coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `${COUPON_PREFIX}${String(i + 1).padStart(5, '0')}`,
userId: order.userId,
orderId: order.id,
totalAmount: order.payAmount,
usedAmount: amount,
balance: Math.max(0, order.payAmount - amount),
status: amount >= order.payAmount ? 'USED_UP' : 'ACTIVE',
sourceProduct: order.productName,
createdAt,
updatedAt: createdAt,
},
});
redeemCount += 1;
await prisma.redeemRecord.create({
data: {
redeemNo: `${REDEEM_PREFIX}${String(redeemCount).padStart(5, '0')}`,
userId: order.userId,
couponId: coupon.id,
storeId: store.id,
amount,
settleAmount,
createdAt,
},
});
}
console.log(
`Done: cities=${cities.length}, promos=${promos.length}, partners=${createdPartners.length}, ` +
`stores=${createdStores.length}, users=${createdUsers.length}, orders=${orderCount}, redeems=${redeemCount}`,
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+964
View File
@@ -0,0 +1,964 @@
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { hashPassword } from '../src/common/crypto/password.util';
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient();
async function createMockResource(
ownerType: ResourceOwnerType,
ownerId: bigint,
bizType: ResourceBizType,
url: string,
) {
return prisma.commonResource.create({
data: {
ownerType,
ownerId,
bizType,
mediaType: ResourceMediaType.IMAGE,
ossBucket: 'mock-dukang',
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
url,
},
});
}
async function main() {
console.log('Seeding v3.1 data...');
await prisma.logUserAnalytics.deleteMany();
await prisma.logPartnerAnalytics.deleteMany();
await prisma.logThirdParty.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany();
await prisma.commonTicket.deleteMany();
await prisma.userPromoAttribution.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany();
await prisma.commonProductItem.deleteMany();
await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
await prisma.commonResource.deleteMany();
await prisma.hqAccount.deleteMany();
const city = await prisma.commonCity.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
localMinQty: 2,
crossMinQty: 6,
},
});
const primaryAccount = await prisma.partnerAccount.create({
data: {
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId: city.id,
scopeType: 'CITY_WIDE',
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
bindingStatus: 'ACTIVE',
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
weeklyStoreTarget: 20,
},
});
await prisma.fulfillmentProvider.create({
data: {
code: 'XFX',
name: '小飞侠',
type: 'API',
status: 'ACTIVE',
settlementMethod: 'PREPAID',
pricingRulesJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
capabilitiesJson: JSON.stringify({
createShipment: true,
getTrack: true,
callback: true,
cancel: true,
}),
configJson: JSON.stringify({
apiUrl: 'https://beta.51xiaoju.cn/app/api/interface.do',
mchId: '',
apiKey: '',
signType: 'MD5',
}),
},
});
const warehouse = await prisma.cityWarehouse.create({
data: {
cityId: city.id,
name: '郑州中央仓',
address: '河南省郑州市金水区物流园1号',
contactName: '仓管张',
contactPhone: '13700000009',
managerType: 'PARTNER',
partnerAccountId: primaryAccount.id,
status: 'ACTIVE',
fulfillmentMode: 'MANUAL',
fulfillmentProviderId: null,
lng: 113.665,
lat: 34.757,
},
});
await prisma.partnerAccount.update({
where: { id: primaryAccount.id },
data: { managedWarehouseId: warehouse.id },
});
await prisma.partnerAccount.create({
data: {
phone: '13700000002',
name: '拓店员小李',
isPrimary: 0,
parentAccountId: primaryAccount.id,
staffRole: 'INTERNAL',
permissions: ['store:create', 'store:view'],
status: 'DISABLED',
},
});
const { DEFAULT_STORE_CATEGORY_TREE } = await import('../src/modules/store/store-category.defaults');
const categoryByCode = new Map<string, { id: bigint }>();
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
const parent = await prisma.commonStoreCategory.create({
data: {
code: root.code,
name: root.name,
sort: root.sort,
parentId: null,
status: 'ACTIVE',
},
});
categoryByCode.set(root.code, parent);
for (const child of root.children) {
const row = await prisma.commonStoreCategory.create({
data: {
code: child.code,
name: child.name,
sort: child.sort,
parentId: parent.id,
status: 'ACTIVE',
},
});
categoryByCode.set(child.code, row);
}
}
const categories = [
categoryByCode.get('HOTPOT')!,
categoryByCode.get('LOCAL')!,
];
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.create({
data: {
code: tpl.code,
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
status: 'ACTIVE',
},
});
}
const productDefs = [
{ skuCode: 'JZ-10', name: '酒祖杜康(国标特级10', subtitle: '清香型 53度', price: 128, sortOrder: 1, img: 'https://picsum.photos/seed/jiuzu10/400/400' },
{ skuCode: 'JZ-15', name: '酒祖杜康(国标特级15', subtitle: '清香型 53度', price: 168, sortOrder: 2, img: 'https://picsum.photos/seed/jiuzu15/400/400' },
{ skuCode: 'JZ-20', name: '酒祖杜康(国标特级20', subtitle: '清香型 53度', price: 298, sortOrder: 3, img: 'https://picsum.photos/seed/jiuzu20/400/400' },
{ skuCode: 'JZ-30', name: '酒祖杜康(国标特级30', subtitle: '清香型 53度', price: 498, sortOrder: 4, img: 'https://picsum.photos/seed/jiuzu30/400/400' },
];
const products = [];
for (const [i, def] of productDefs.entries()) {
const product = await prisma.commonProductItem.create({
data: {
skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`,
name: def.name,
subtitle: def.subtitle,
aromaType: 'QINGXIANG',
spec: '500ml | 53度',
price: def.price,
benefitAmount: def.price,
status: 'ON_SALE',
sortOrder: def.sortOrder,
},
});
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
products.push(product);
}
const storeDefs = [
{
name: '郑州老城店',
phone: '13910000001',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id,
settlementRate: 0.6,
withAccount: true,
},
{
name: '郑州美食城店',
phone: '13910000002',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id,
settlementRate: 0.65,
withAccount: false,
},
];
const createdStores: { id: bigint; name: string }[] = [];
for (const def of storeDefs) {
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerAccountId: primaryAccount.id,
categoryId: def.categoryId,
name: def.name,
phone: def.phone,
province: '河南省',
cityName: '郑州市',
district: def.district,
address: def.address,
intro: def.intro,
settlementRate: def.settlementRate,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
},
});
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
createdStores.push({ id: store.id, name: def.name });
}
// 一号两店:主账号 13910000001 绑定老城店 + 美食城店,便于测选店
const multiStorePrimary = await prisma.storeAccount.create({
data: {
phone: '13910000001',
name: '郑州老城店主',
isPrimary: 1,
bankAccountName: '郑州老城店主',
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
bindings: {
create: createdStores.map((s) => ({ storeId: s.id })),
},
},
});
// 子账号样例:仅绑定第一家店
await prisma.storeAccount.create({
data: {
phone: '13910000011',
name: '老城店收银员',
isPrimary: 0,
parentAccountId: multiStorePrimary.id,
staffRole: 'CASHIER',
permissions: ['redeem', 'records'],
status: 'ACTIVE',
bindings: {
create: [{ storeId: createdStores[0].id }],
},
},
});
const weekStart = (() => {
const d = new Date();
d.setHours(12, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
d.setHours(10, 0, 0, 0);
return d;
})();
const newWeekStore = await prisma.store.create({
data: {
cityId: city.id,
partnerAccountId: primaryAccount.id,
categoryId: categories[0].id,
name: '本周新签体验店',
phone: '13910000003',
province: '河南省',
cityName: '郑州市',
district: '中原区',
address: '建设路88号',
intro: '本周新签约门店',
settlementRate: 0.6,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
},
});
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
await prisma.storeAccount.create({
data: {
phone: '13910000003',
name: '本周新签体验店',
isPrimary: 1,
bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行',
bindings: { create: [{ storeId: newWeekStore.id }] },
},
});
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
phoneVerifiedAt: new Date(),
nickname: '测试用户',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
loginName: 'admin',
passwordHash: hashPassword('dukang@123!'),
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKHQ001',
name: '总部品鉴会',
scene: 'EVENT',
qrcodeId: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
status: 'ACTIVE',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKDEMO1',
name: '郑州品鉴会演示',
scene: 'OFFLINE_PICKUP',
qrcodeId: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
status: 'ACTIVE',
},
});
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
const product = products[0];
const orderDefs = [
{ dayOffset: 0, payAmount: 1198, quantity: 2 },
{ dayOffset: 1, payAmount: 599, quantity: 1 },
{ dayOffset: 2, payAmount: 1760, quantity: 2 },
{ dayOffset: 4, payAmount: 1299, quantity: 1 },
{ dayOffset: 5, payAmount: 880, quantity: 1 },
];
const coupons: { id: bigint; balance: number }[] = [];
for (const [index, def] of orderDefs.entries()) {
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
const listAmount = def.payAmount;
const order = await prisma.order.create({
data: {
orderNo: `WK${Date.now()}${index}`,
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
quantity: def.quantity,
listUnitPrice: product.price,
listAmount,
productAmount: listAmount,
payAmount: listAmount,
benefitAmount: listAmount,
receiverName: '测试用户',
receiverPhone: user.phone!,
receiverAddress: '郑州市金水区测试路1号',
receiverProvince: '河南省',
receiverCity: '郑州市',
receiverDistrict: '金水区',
paidAt,
completedAt: paidAt,
partnerAccountIdAtPay: primaryAccount.id,
orderCommissionRateAtPay: 0,
},
});
const coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `CPN${Date.now()}${index}`,
userId: user.id,
orderId: order.id,
totalAmount: listAmount,
balance: listAmount,
sourceProduct: product.name,
},
});
coupons.push({ id: coupon.id, balance: listAmount });
}
const redeemDefs = [
{ storeIndex: 0, amount: 42800, dayOffset: 1, settlementRate: 0.6 },
{ storeIndex: 1, amount: 38400, dayOffset: 2, settlementRate: 0.65 },
{ storeIndex: 2, amount: 31200, dayOffset: 3, settlementRate: 0.6 },
];
for (const [index, def] of redeemDefs.entries()) {
const coupon = coupons[index];
const store = createdStores[def.storeIndex];
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
const settleAmount = Math.round(def.amount * def.settlementRate * 100) / 100;
await prisma.redeemRecord.create({
data: {
redeemNo: `RD${Date.now()}${index}`,
userId: user.id,
couponId: coupon.id,
storeId: store.id,
amount: def.amount,
settleAmount,
createdAt,
},
});
await prisma.benefitCoupon.update({
where: { id: coupon.id },
data: {
usedAmount: def.amount,
balance: Math.max(0, coupon.balance - def.amount),
},
});
}
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerAccountId: primaryAccount.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'UNPAID',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
warehouse: warehouse.name,
primaryAccount: primaryAccount.phone,
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
products: products.length,
stores: createdStores.length,
testPhones: {
user: '13800000001',
partner: '13700000001',
partnerStaff: '13700000002',
hq: '13600000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,72 @@
/**
* 一次性迁移:空表时从 .env / 旧 dev_plan_settings 写入默认消息推送。
* 也可由 WecomMessagePushService.ensureDefaults() 在 API 启动时执行。
*/
import { PrismaClient } from '@prisma/client';
import {
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
} from '@dukang/shared-types';
const prisma = new PrismaClient();
async function main() {
const count = await prisma.wecomMessagePush.count();
if (count > 0) {
console.log(`wecom_message_push already has ${count} row(s), skip seed`);
return;
}
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
if (alertUrl) {
await prisma.wecomMessagePush.create({
data: {
name: '运营告警',
webhookUrl: alertUrl,
enabled: process.env.WECOM_ALERT_ENABLED !== 'false',
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
sortOrder: 0,
},
});
console.log('created push: 运营告警');
}
try {
const rows = await prisma.$queryRawUnsafe<
Array<{
task_dispatch_webhook_url: string | null;
task_dispatch_wecom_user_id: string | null;
task_dispatch_enabled: number | boolean | null;
}>
>(
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
);
const row = rows[0];
if (row?.task_dispatch_webhook_url?.trim()) {
await prisma.wecomMessagePush.create({
data: {
name: '开发任务派发',
webhookUrl: row.task_dispatch_webhook_url.trim(),
enabled: !!row.task_dispatch_enabled,
mentionWecomUserId: row.task_dispatch_wecom_user_id?.trim() || null,
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
sortOrder: 10,
},
});
console.log('created push: 开发任务派发');
}
} catch (e) {
console.log('dev_plan_settings task_dispatch columns not found, skip dev dispatch migration');
}
console.log('wecom message push seed done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -0,0 +1,89 @@
/** 商品详情模板默认种子(与 admin-web 历史静态模板一致) */
export const DEFAULT_PRODUCT_DETAIL_TEMPLATES = [
{
code: 'dukang-classic',
name: '杜康经典',
description: '品牌故事 + 双卖点,适合主力 SKU',
aromaType: null as string | null,
storyTitle: '千年杜康 · 唯有此处',
storyText:
'选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。',
features: [
{ icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' },
{ icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' },
],
suggestedDetailImageCount: 2,
sortOrder: 1,
},
{
code: 'qingxiang',
name: '清香型',
description: '突出清香甘冽、入口绵柔',
aromaType: 'QINGXIANG',
storyTitle: '清香传世 · 入口甘冽',
storyText:
'以优质高粱、大麦、豌豆为原料,地缸固态发酵,酒体清澈透明,清香纯正,甘冽爽口,余味悠长。',
features: [
{ icon: 'air', title: '清香纯正', desc: '窖香优雅 入口绵甜' },
{ icon: 'local_drink', title: '纯粮固态', desc: '地缸发酵 传统工艺' },
],
suggestedDetailImageCount: 2,
sortOrder: 2,
},
{
code: 'jiangxiang',
name: '酱香型',
description: '突出酱香突出、回味悠长',
aromaType: 'JIANGXIANG',
storyTitle: '酱香典范 · 岁月醇香',
storyText:
'遵循端午制曲、重阳下沙,九次蒸煮八次发酵,长期窖藏陈化,酱香突出,幽雅细腻,空杯留香持久。',
features: [
{ icon: 'schedule', title: '陈年窖藏', desc: '时光淬炼 醇厚丰满' },
{ icon: 'spa', title: '酱香工艺', desc: '九蒸八酵 匠心酿造' },
],
suggestedDetailImageCount: 3,
sortOrder: 3,
},
{
code: 'nongxiang',
name: '浓香型',
description: '突出窖香浓郁、绵甜爽净',
aromaType: 'NONGXIANG',
storyTitle: '浓香典范 · 窖香天成',
storyText:
'泥窖固态发酵,续糟配料,蒸馏摘酒,分级贮存。酒体无色透明,窖香浓郁,绵甜爽净,尾净余香长。',
features: [
{ icon: 'foundation', title: '百年窖池', desc: '微生物群落 窖香天成' },
{ icon: 'water_drop', title: '绵甜爽净', desc: '入口醇和 回味悠长' },
],
suggestedDetailImageCount: 2,
sortOrder: 4,
},
{
code: 'benefit-focus',
name: '好客权益',
description: '强调购酒享权益、到店核销',
aromaType: null,
storyTitle: '买杜康美酒 · 享全城好客礼遇',
storyText:
'购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。',
features: [
{ icon: 'confirmation_number', title: '购酒发券', desc: '支付成功即享权益额度' },
{ icon: 'store', title: '全城好店', desc: '本地签约门店随心核销' },
],
suggestedDetailImageCount: 1,
sortOrder: 5,
},
{
code: 'minimal',
name: '简约留白',
description: '仅故事正文,无卖点模块',
aromaType: null,
storyTitle: '商品详情',
storyText: '请在上方上传详情长图,或在此补充商品说明文字。',
features: [] as Array<{ icon: string; title: string; desc: string }>,
suggestedDetailImageCount: 1,
sortOrder: 6,
},
] as const;
@@ -0,0 +1,7 @@
-- Optional one-time data remap before/after prisma db push when upgrading PartnerBillStatus.
-- Run against MySQL if legacy DRAFT/CONFIRMED rows exist.
-- Step A (before removing old enum values): widen enum if needed via Prisma push,
-- then remap:
UPDATE partner_bill SET status = 'PENDING_REVIEW' WHERE status = 'DRAFT';
UPDATE partner_bill SET status = 'UNPAID' WHERE status = 'CONFIRMED';
@@ -0,0 +1,44 @@
/**
* 将 common_product_item.benefit_amount 同步为与 price 相同(全额好客权益)。
* 用法:pnpm db:sync-benefit
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const products = await prisma.commonProductItem.findMany({
select: { id: true, skuCode: true, name: true, price: true, benefitAmount: true },
orderBy: { sortOrder: 'asc' },
});
if (products.length === 0) {
console.log('No products found. Run pnpm prisma:seed first.');
return;
}
console.log(`Syncing benefit_amount = price for ${products.length} product(s)...\n`);
for (const p of products) {
const price = Number(p.price);
const before = p.benefitAmount != null ? Number(p.benefitAmount) : null;
await prisma.commonProductItem.update({
where: { id: p.id },
data: { benefitAmount: p.price },
});
const beforeLabel = before == null ? 'NULL' : `¥${before}`;
console.log(` ${p.skuCode} ${p.name}`);
console.log(` benefit: ${beforeLabel} → ¥${price}`);
}
console.log('\nDone.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,46 @@
import { PrismaClient } from '@prisma/client';
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient();
async function main() {
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.upsert({
where: { code: tpl.code },
create: {
code: tpl.code,
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
status: 'ACTIVE',
},
update: {
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
},
});
}
console.log(`Upserted ${DEFAULT_PRODUCT_DETAIL_TEMPLATES.length} product detail templates`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS `wecom_bot` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL,
`role` VARCHAR(32) NOT NULL,
`bot_id` VARCHAR(128) NOT NULL,
`secret` VARCHAR(256) NOT NULL,
`avatar_url` VARCHAR(512) NULL,
`welcome` VARCHAR(1024) NULL,
`permissions` TEXT NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT true,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `wecom_bot_bot_id_key`(`bot_id`),
INDEX `wecom_bot_enabled_sort_order_idx`(`enabled`, `sort_order`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,123 @@
const fs = require('fs');
const path = require('path');
const apiRoot = path.join(__dirname, '..');
const prod = fs.readFileSync(path.join(apiRoot, '.env.production'), 'utf8');
const map = new Map();
for (const line of prod.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m) map.set(m[1], m[2]);
}
function unq(v) {
if (!v) return '';
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
return v.slice(1, -1);
}
return v;
}
function q(v) {
return JSON.stringify(String(v ?? ''));
}
const db = unq(map.get('DATABASE_URL') || '');
if (!db.includes('dukang_prod')) {
console.error('prod DATABASE_URL unexpected, refuse to rewrite');
process.exit(1);
}
const stagingDb = db.replace('/dukang_prod', '/dukang_staging');
const jwt = `${unq(map.get('JWT_SECRET') || 'prod')}-staging`;
const fixed = {
APP_ENV: 'staging',
NODE_ENV: 'production',
DATABASE_URL: stagingDb,
REDIS_URL: 'redis://localhost:6379/1',
JWT_SECRET: jwt,
JWT_EXPIRES_IN: unq(map.get('JWT_EXPIRES_IN') || '7d'),
PORT: '8190',
MOCK_SMS: 'true',
MOCK_PAY: 'true',
MOCK_DELIVERY_AUTO: 'true',
MOCK_WECHAT: 'true',
AUTO_APPROVE_STORE: 'true',
TRUST_PROXY: 'true',
USER_H5_URL: 'https://user-test.dukanghaoke.com/user',
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
OSS_UPLOAD_PREFIX: 'staging/uploads',
WECOM_AIBOT_ENABLED: 'false',
};
const preferFromProd = [
'ALIYUN_SMS_SIGN_NAME',
'ALIYUN_SMS_TEMPLATE_CODE',
'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM',
'ALIYUN_SMS_TEMPLATE_PROXY_ORDER',
'ALIYUN_SMS_ACCESS_KEY_ID',
'ALIYUN_SMS_ACCESS_KEY_SECRET',
'WX_APP_ID',
'WX_APP_SECRET',
'WX_MINI_APP_ID',
'WX_MINI_APP_SECRET',
'WX_MINI_PROMO_PAGE',
'WX_MCH_ID',
'WX_MCH_SERIAL_NO',
'WX_MCH_PRIVATE_KEY',
'WX_API_V3_KEY',
'WX_PLATFORM_CERT',
'WX_MINI_MSG_TOKEN',
'WX_MINI_MSG_AES_KEY',
'OSS_ACCESS_KEY_ID',
'OSS_ACCESS_KEY_SECRET',
'OSS_BUCKET',
'OSS_REGION',
'OSS_CDN_BASE',
'OSS_ENDPOINT',
'OSS_UPLOAD_EXPIRE_SECONDS',
'OSS_MAX_UPLOAD_BYTES',
'COURIER_PROVIDER',
'XIAOFEIXIA_API_URL',
'XIAOFEIXIA_MCH_ID',
'XIAOFEIXIA_API_KEY',
'XIAOFEIXIA_SIGN_TYPE',
'SHIP_FROM_NAME',
'SHIP_FROM_MOBILE',
'SHIP_FROM_ADDRESS',
'SHIP_FROM_ADDRESS_DETAIL',
'SHIP_FROM_LNG',
'SHIP_FROM_LAT',
'TENCENT_LBS_KEY',
'TENCENT_LBS_SECRET_KEY',
'DEPLOY_WEBHOOK_URL',
'DEPLOY_WEBHOOK_SECRET',
];
const out = [
'# Generated from .env.production for staging (full Mock + *-test domains)',
'# Do not commit. Sync: bash deploy/sync-api-env.sh staging',
'',
];
for (const [k, v] of Object.entries(fixed)) {
out.push(`${k}=${q(v)}`);
}
out.push('');
for (const k of preferFromProd) {
if (map.has(k) && !(k in fixed)) {
out.push(`${k}=${q(unq(map.get(k)))}`);
}
}
const skip = new Set([...Object.keys(fixed), ...preferFromProd]);
for (const [k, raw] of map.entries()) {
if (skip.has(k)) continue;
out.push(`${k}=${q(unq(raw))}`);
}
fs.writeFileSync(path.join(apiRoot, '.env.staging'), `${out.join('\n')}\n`);
const host = (stagingDb.match(/@([^/:]+)/) || [])[1] || '?';
console.log(`ok db=dukang_staging host=${host} redis=/1 mocks=on`);
@@ -0,0 +1,27 @@
const fs = require('fs');
const path = require('path');
const apiRoot = path.join(__dirname, '..');
const prod = fs.readFileSync(path.join(apiRoot, '.env.production'), 'utf8');
const m = prod.match(/^DATABASE_URL=(.*)$/m);
if (!m) throw new Error('no prod DATABASE_URL');
let url = m[1].trim();
if (
(url.startsWith('"') && url.endsWith('"')) ||
(url.startsWith("'") && url.endsWith("'"))
) {
url = url.slice(1, -1);
}
if (!url.includes('/dukang_prod')) throw new Error('unexpected prod url');
const stg = url.replace('/dukang_prod', '/dukang_staging');
const envPath = path.join(apiRoot, '.env.staging');
let t = fs.readFileSync(envPath, 'utf8');
if (!/^DATABASE_URL=/m.test(t)) {
t = `DATABASE_URL=\n${t}`;
}
t = t.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${JSON.stringify(stg)}`);
fs.writeFileSync(envPath, t);
const u = new URL(stg);
console.log(`ok ${u.hostname}:${u.port || 3306}${u.pathname}`);
@@ -0,0 +1,68 @@
/**
* 发版成功后写入 system_version。
* 须经 with-api-env.cjs 加载 DATABASE_URL;失败不阻断发版(exit 0)。
*/
const { execSync } = require('child_process');
const { resolve } = require('path');
const apiRoot = resolve(__dirname, '..');
const appRoot = process.env.APP_ROOT || resolve(apiRoot, '../..');
function git(cmd) {
try {
return execSync(cmd, { cwd: appRoot, encoding: 'utf8' }).trim();
} catch {
return null;
}
}
async function main() {
const commitId = git('git rev-parse HEAD');
if (!commitId) {
console.warn('[record-system-version] WARN: 无法读取 git HEAD,跳过');
return;
}
let commitMessage = git('git log -1 --pretty=%s') || '';
if (commitMessage.length > 512) {
commitMessage = commitMessage.slice(0, 512);
}
const gitTag = git('git describe --tags --exact-match') || null;
const branchRaw = git('git rev-parse --abbrev-ref HEAD');
const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null;
const trigger = (process.env.DEPLOY_TRIGGER || '').trim();
let deployedBy = 'manual';
if (trigger) {
if (trigger === 'manual' || trigger === 'admin') {
deployedBy = trigger;
} else {
deployedBy = 'webhook';
}
}
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
try {
const row = await prisma.systemVersion.create({
data: {
gitTag,
commitId,
commitMessage: commitMessage || '(no message)',
branch,
deployedBy,
},
});
console.log(
`[record-system-version] ok id=${row.id} commit=${commitId.slice(0, 7)} tag=${gitTag || '-'} by=${deployedBy}`,
);
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.warn('[record-system-version] WARN:', err instanceof Error ? err.message : err);
process.exit(0);
});
@@ -0,0 +1,100 @@
import '../src/load-env';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/common/prisma/prisma.module';
import { RedeemService } from '../src/modules/redeem/redeem.service';
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
const prisma = app.get(PrismaService);
const redeem = app.get(RedeemService);
let coupon = await prisma.benefitCoupon.findFirst({
where: { status: 'ACTIVE', balance: { gt: 10 } },
orderBy: { createdAt: 'desc' },
});
if (!coupon) {
const user = await prisma.user.findFirst({ orderBy: { id: 'asc' } });
const city = await prisma.commonCity.findFirst();
const product = await prisma.commonProductItem.findFirst();
if (!user || !city || !product) throw new Error('seed base data missing');
const order = await prisma.order.create({
data: {
orderNo: `T${Date.now()}`,
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec ?? '500ml',
quantity: 2,
listUnitPrice: 100,
listAmount: 200,
productAmount: 200,
payAmount: 200,
benefitAmount: 200,
receiverName: 'test',
receiverPhone: user.phone ?? '13800000001',
receiverProvince: '河南',
receiverCity: '郑州',
receiverDistrict: '金水',
receiverAddress: 'test addr',
},
});
coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `TEST${Date.now()}`,
userId: user.id,
orderId: order.id,
totalAmount: 200,
usedAmount: 0,
balance: 200,
status: 'ACTIVE',
sourceProduct: product.name,
},
});
console.log('created test coupon', coupon.id.toString());
}
const storeAccount = await prisma.storeAccount.findFirst({
where: { status: 'ACTIVE', store: { status: 'OPEN' } },
include: { store: true },
});
if (!storeAccount) throw new Error('no active store account on OPEN store');
console.log('userId', coupon.userId.toString());
console.log('storeId', storeAccount.storeId.toString());
console.log('coupon balance', coupon.balance.toString());
const tokenRes = await redeem.createToken(coupon.userId, {
amount: 10,
storeId: storeAccount.storeId.toString(),
});
console.log('token created', tokenRes.token);
try {
const preview = await redeem.previewRedeem(storeAccount.id, tokenRes.token);
console.log('preview ok', JSON.stringify(preview));
} catch (e) {
console.error('preview failed:', e);
throw e;
}
try {
const record = await redeem.confirmRedeem(storeAccount.id, { token: tokenRes.token });
console.log('confirm ok', JSON.stringify(record));
} catch (e) {
console.error('confirm failed:', e);
throw e;
}
await app.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,89 @@
/**
* 上传小程序「资质公示」静态图到 OSSstatic/mini-user/qualification-disclosure.png
* 用法(在 server/dukang-api:
* node scripts/upload-qualification-disclosure.mjs [本地 png 路径]
* 也可设环境变量 QUALIFICATION_DISCLOSURE_LOCAL。
*/
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname, isAbsolute } from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const OSS = require('ali-oss');
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiRoot = resolve(__dirname, '..');
function loadEnvFile(path) {
if (!existsSync(path)) return {};
const out = {};
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (!m) continue;
let v = m[2];
if (
(v.startsWith('"') && v.endsWith('"')) ||
(v.startsWith("'") && v.endsWith("'"))
) {
v = v.slice(1, -1);
}
out[m[1]] = v;
}
return out;
}
const env = {
...loadEnvFile(resolve(apiRoot, '.env.production')),
...loadEnvFile(resolve(apiRoot, '.env.development')),
...loadEnvFile(resolve(apiRoot, '.env')),
};
const accessKeyId = env.OSS_ACCESS_KEY_ID || '';
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET || '';
const bucket = env.OSS_BUCKET || '';
const region = env.OSS_REGION || 'oss-cn-beijing';
const cdnBase = (env.OSS_CDN_BASE || '').replace(/\/$/, '');
if (!accessKeyId || !accessKeySecret || !bucket) {
console.error('缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
process.exit(1);
}
const argPath = process.argv[2]?.trim() || env.QUALIFICATION_DISCLOSURE_LOCAL?.trim() || '';
if (!argPath) {
console.error(
'请传入本地 png 路径,例如:\n node scripts/upload-qualification-disclosure.mjs D:/tmp/qualification-disclosure.png',
);
process.exit(1);
}
const localFile = isAbsolute(argPath) ? argPath : resolve(process.cwd(), argPath);
if (!existsSync(localFile)) {
console.error('本地文件不存在:', localFile);
process.exit(1);
}
const ossKey = 'static/mini-user/qualification-disclosure.png';
const client = new OSS({
region,
accessKeyId,
accessKeySecret,
bucket,
});
const buffer = readFileSync(localFile);
await client.put(ossKey, buffer, {
mime: 'image/png',
headers: {
'Content-Disposition': 'inline',
'Cache-Control': 'public, max-age=31536000',
},
});
const url = cdnBase
? `${cdnBase}/${ossKey}`
: `https://${bucket}.${region}.aliyuncs.com/${ossKey}`;
console.log('uploaded:', ossKey);
console.log('url:', url);
@@ -0,0 +1,46 @@
import '../src/load-env';
import { PrismaClient } from '@prisma/client';
import { hashPassword } from '../src/common/crypto/password.util';
const LOGIN_NAME = process.env.SUPER_ADMIN_LOGIN ?? 'admin';
const PASSWORD = process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!';
const PLACEHOLDER_PHONE = process.env.SUPER_ADMIN_PHONE ?? '19900000001';
async function main() {
const prisma = new PrismaClient();
const passwordHash = hashPassword(PASSWORD);
try {
const deleted = await prisma.$executeRaw`
DELETE FROM hq_account WHERE admin_role = 'SUPER_ADMIN'
`;
console.log(`Deleted ${deleted} SUPER_ADMIN account row(s).`);
await prisma.$executeRaw`
INSERT INTO hq_account (
phone, login_name, password_hash, name, admin_role, status, created_at, updated_at
) VALUES (
${PLACEHOLDER_PHONE},
${LOGIN_NAME},
${passwordHash},
${'超级管理员'},
${'SUPER_ADMIN'},
${'ACTIVE'},
NOW(3),
NOW(3)
)
`;
console.log('Created SUPER_ADMIN:', {
loginName: LOGIN_NAME,
phone: PLACEHOLDER_PHONE,
password: '(hidden)',
});
} finally {
await prisma.$disconnect();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,31 @@
const { config } = require('dotenv');
const { spawnSync } = require('child_process');
const { existsSync } = require('fs');
const { resolve } = require('path');
const apiRoot = resolve(__dirname, '..');
const envFile = process.env.DUKANG_ENV_FILE
?? (process.env.APP_ENV === 'staging' ? '.env.staging' : '.env.production');
const envPath = resolve(apiRoot, envFile);
if (!existsSync(envPath)) {
console.error(`[with-api-env] 未找到 ${envPath}`);
process.exit(1);
}
config({ path: envPath, override: true });
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('用法: node scripts/with-api-env.cjs <command> [args...]');
process.exit(1);
}
const result = spawnSync(args[0], args.slice(1), {
cwd: apiRoot,
stdio: 'inherit',
env: process.env,
shell: true,
});
process.exit(result.status ?? 1);
+68
View File
@@ -0,0 +1,68 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { PrismaModule } from './common/prisma/prisma.module';
import { GeoModule } from './common/geo/geo.module';
import { RedisModule } from './common/redis/redis.module';
import { AlertModule } from './common/alert/alert.module';
import { HealthModule } from './modules/health/health.module';
import { IamModule } from './modules/iam/iam.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { TradeModule } from './modules/trade/trade.module';
import { BenefitModule } from './modules/benefit/benefit.module';
import { StoreModule } from './modules/store/store.module';
import { RedeemModule } from './modules/redeem/redeem.module';
import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { PromoModule } from './modules/promo/promo.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
import { LoggingModule } from './common/logging/logging.module';
import { RequestIdMiddleware } from './common/logging/request-id.middleware';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true }),
BullModule.forRoot({
connection: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
}),
PrismaModule,
SystemConfigModule,
GeoModule,
RedisModule,
AlertModule,
HealthModule,
IamModule,
CatalogModule,
TradeModule,
BenefitModule,
StoreModule,
RedeemModule,
SettlementModule,
AnalyticsModule,
JobsModule,
OpsModule,
PromoModule,
CityScopeModule,
CommonModule,
DevPlanModule,
HqOperationModule,
LoggingModule,
CallbacksModule,
WecomModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { IntegrationsModule } from '../integrations/integrations.module';
import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [
WechatPayCallbackController,
WechatRefundCallbackController,
WechatMessageCallbackController,
DeliveryCallbackController,
],
})
export class CallbacksModule {}
@@ -0,0 +1,71 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TradeService } from '../modules/trade/trade.service';
import { CourierService } from '../integrations/courier/courier.service';
import { PrismaService } from '../common/prisma/prisma.module';
import { logCourierCall } from '../integrations/courier/courier-log.util';
@Controller('callbacks/delivery')
export class DeliveryCallbackController {
constructor(
private readonly tradeService: TradeService,
private readonly courier: CourierService,
private readonly prisma: PrismaService,
) {}
@Post('track')
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
const baseLog = {
scene: 'TRACK_CALLBACK',
requestUrl: '/api/v1/callbacks/delivery/track',
requestBody: body as Record<string, unknown>,
};
if (!body.orderId && !body.orderNo) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '缺少 orderId / orderNo',
});
return this.courier.buildTrackCallbackResponse(false);
}
const order = body.orderId
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
if (!order) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '订单不存在',
externalNo: body.orderNo,
});
return this.courier.buildTrackCallbackResponse(false);
}
const statusMap: Record<string, string> = {
SHIPPED: 'SHIPPING',
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
DELIVERED: 'COMPLETED',
COMPLETED: 'COMPLETED',
};
const target = statusMap[body.status ?? ''] ?? body.status;
let applied = false;
if (target && target !== order.status) {
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
applied = true;
}
const response = this.courier.buildTrackCallbackResponse(true);
await logCourierCall(this.prisma, {
...baseLog,
responseBody: response,
status: 'SUCCESS',
externalNo: order.orderNo,
ref: { refType: 'ORDER', refId: order.id },
errorMessage: applied ? undefined : '状态未变更',
});
return response;
}
}
@@ -0,0 +1,118 @@
import {
Controller,
Get,
HttpStatus,
Logger,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
decryptWechatEncrypt,
normalizeTradeManageEvent,
parseWechatPushBody,
verifyWechatMsgSignature,
verifyWechatUrlSignature,
} from '../integrations/wechat/wechat-msg-crypto.util';
import { WechatTradeManageService } from '../integrations/wechat/wechat-trade-manage.service';
/**
* 小程序消息推送(发货信息管理事件)。
* 后台配置 URLhttps://{host}/api/v1/callbacks/wechat/message
* 需配置 WX_MINI_MSG_TOKEN;安全模式另需 WX_MINI_MSG_AES_KEY。
*/
@Controller('callbacks/wechat')
export class WechatMessageCallbackController {
private readonly logger = new Logger(WechatMessageCallbackController.name);
constructor(private readonly tradeManage: WechatTradeManageService) {}
@Get('message')
verify(
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('echostr') echostr: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
this.logger.error('WX_MINI_MSG_TOKEN 未配置,无法完成消息推送 URL 验证');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
this.logger.warn('wechat message URL verify failed');
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
return res.status(HttpStatus.OK).send(echostr ?? '');
}
@Post('message')
async receive(
@Req() req: Request & { rawBody?: Buffer },
@Query('signature') signature: string,
@Query('timestamp') timestamp: string,
@Query('nonce') nonce: string,
@Query('msg_signature') msgSignature: string,
@Query('encrypt_type') encryptType: string,
@Res() res: Response,
) {
const token = (process.env.WX_MINI_MSG_TOKEN || '').trim();
if (!token) {
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('msg token not configured');
}
try {
const raw =
req.rawBody?.toString('utf8') ??
(typeof req.body === 'string'
? req.body
: Buffer.isBuffer(req.body)
? req.body.toString('utf8')
: JSON.stringify(req.body ?? {}));
let body = parseWechatPushBody(raw);
const encrypt = typeof body.Encrypt === 'string' ? body.Encrypt : undefined;
const secure = encryptType === 'aes' || !!encrypt;
if (secure) {
if (!encrypt) {
return res.status(HttpStatus.BAD_REQUEST).send('missing Encrypt');
}
const aesKey = (process.env.WX_MINI_MSG_AES_KEY || '').trim();
if (!aesKey) {
this.logger.error('安全模式推送但未配置 WX_MINI_MSG_AES_KEY');
return res.status(HttpStatus.SERVICE_UNAVAILABLE).send('aes key not configured');
}
if (
!verifyWechatMsgSignature(token, timestamp, nonce, encrypt, msgSignature || String(body.MsgSignature || ''))
) {
return res.status(HttpStatus.FORBIDDEN).send('invalid msg_signature');
}
const appId = (process.env.WX_MINI_APP_ID || process.env.WX_APP_ID || '').trim();
const plain = decryptWechatEncrypt(encrypt, aesKey, appId || undefined);
body = parseWechatPushBody(plain);
} else if (!verifyWechatUrlSignature(token, timestamp, nonce, signature)) {
return res.status(HttpStatus.FORBIDDEN).send('invalid signature');
}
// 云托管探活
if (String(body.action || '') === 'CheckContainerPath') {
return res.status(HttpStatus.OK).send('success');
}
const evt = normalizeTradeManageEvent(body);
if (evt.event) {
await this.tradeManage.handleEvent(evt);
}
return res.status(HttpStatus.OK).send('success');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`wechat message push failed: ${message}`);
// 仍回 success,避免微信疯狂重试;错误已记日志
return res.status(HttpStatus.OK).send('success');
}
}
}
@@ -0,0 +1,53 @@
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TradeService } from '../modules/trade/trade.service';
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
import { PayRedeemAnomalyService } from '../common/alert/pay-redeem-anomaly.service';
import { AlertService } from '../common/alert/alert.service';
type RawBodyRequest = Request & { body: Buffer };
@Controller('callbacks/wechat')
export class WechatPayCallbackController {
constructor(
private readonly tradeService: TradeService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
) {}
@Post('pay')
async payNotify(
@Req() req: RawBodyRequest,
@Headers() headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
let orderNo: string | undefined;
try {
const rawBody =
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const notify = await this.wechat.parsePayNotification(headers, rawBody);
orderNo = notify.outTradeNo;
await this.tradeService.handlePaySuccess({
orderNo: notify.outTradeNo,
transactionId: notify.transactionId,
amountFen: notify.amountFen,
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
this.payRedeemAnomaly.onPayFail(message, { orderNo });
this.alert.notify({
level: 'P0',
category: 'pay',
title: '支付回调处理失败',
detail: `${orderNo ? `订单 ${orderNo}\n` : ''}${message}`,
dedupeKey: `pay_callback_fail|${orderNo ?? message.slice(0, 40)}`,
dedupeTtlSec: 120,
});
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -0,0 +1,52 @@
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TradeService } from '../modules/trade/trade.service';
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
import { AlertService } from '../common/alert/alert.service';
type RawBodyRequest = Request & { body: Buffer };
@Controller('callbacks/wechat')
export class WechatRefundCallbackController {
constructor(
private readonly tradeService: TradeService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
private readonly alert: AlertService,
) {}
@Post('refund')
async refundNotify(
@Req() req: RawBodyRequest,
@Headers() headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
let outRefundNo: string | undefined;
try {
const rawBody =
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const notify = await this.wechat.parseRefundNotification(headers, rawBody);
outRefundNo = notify.outRefundNo;
await this.tradeService.handleRefundSuccess({
outRefundNo: notify.outRefundNo,
refundId: notify.refundId,
amountFen: notify.amountFen,
remark: '微信退款回调确认',
actorType: 'WECHAT_REFUND',
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
this.alert.notify({
level: 'P0',
category: 'pay',
title: '退款回调处理失败',
detail: `${outRefundNo ? `退款单 ${outRefundNo}\n` : ''}${message}`,
dedupeKey: `refund_callback_fail|${outRefundNo ?? message.slice(0, 40)}`,
dedupeTtlSec: 120,
});
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -0,0 +1,30 @@
/** 告警级别 */
export type AlertLevel = 'P0' | 'P1' | 'P2';
/** 支付 / 核销行为异常阈值(金额单位:元) */
export const ALERT_THRESHOLDS = {
payAmountMinYuan: 1,
payAmountMaxYuan: 5000,
payAttemptPerMinute: 3,
payFailPerMinute: 5,
redeemAmountMinYuan: 1,
redeemAmountMaxYuan: 1000,
redeemAttemptPerMinute: 3,
redeemFailPerMinute: 5,
} as const;
/** Redis 分钟桶计数 TTL(秒) */
export const ALERT_RATE_BUCKET_TTL_SEC = 120;
/** 默认去重 TTL */
export const ALERT_DEDUPE_TTL_SEC = 600;
/** 频次类告警去重 TTL(同一分钟桶只推一次) */
export const ALERT_RATE_DEDUPE_TTL_SEC = 90;
/** 卡住订单扫描 */
export const ALERT_STUCK_ORDER = {
pendingShipHours: 24,
inDeliveryHours: 48,
sampleLimit: 5,
} as const;
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '../redis/redis.module';
import { AlertService } from './alert.service';
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
/**
* 运营告警(企微 Webhook 多实例)。Global 以便 Filter / 各业务 Module 注入。
*/
@Global()
@Module({
imports: [RedisModule],
providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
exports: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
})
export class AlertModule {}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import type { WecomPushCondition } from '@dukang/shared-types';
import { RedisService } from '../redis/redis.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { ALERT_DEDUPE_TTL_SEC, type AlertLevel } from './alert.constants';
export type AlertNotifyInput = {
level: AlertLevel;
category: string;
title: string;
detail: string;
dedupeKey: string;
dedupeTtlSec?: number;
/** 覆盖默认事件 key 映射 */
eventKeys?: WecomPushCondition[];
};
@Injectable()
export class AlertService {
private readonly logger = new Logger(AlertService.name);
constructor(
private readonly redis: RedisService,
private readonly wecomPush: WecomMessagePushService,
) {}
/** 异步告警,不阻塞调用方 */
notify(input: AlertNotifyInput): void {
void this.notifyAsync(input).catch((e) => {
this.logger.warn(`alert notify failed: ${e instanceof Error ? e.message : String(e)}`);
});
}
async notifyAsync(input: AlertNotifyInput): Promise<boolean> {
const eventKeys = resolveAlertEventKeys(input);
const hasAny = await Promise.all(eventKeys.map((k) => this.wecomPush.hasEnabledPushes(k)));
if (!hasAny.some(Boolean)) return false;
const ttl = input.dedupeTtlSec ?? ALERT_DEDUPE_TTL_SEC;
const dedupeRedisKey = `alert:dedupe:${input.dedupeKey}`;
try {
const ok = await this.redis.client.set(dedupeRedisKey, '1', 'EX', ttl, 'NX');
if (ok !== 'OK') return false;
} catch (e) {
this.logger.warn(
`alert dedupe redis error, send anyway: ${e instanceof Error ? e.message : String(e)}`,
);
}
return this.sendMarkdownNow(input, eventKeys);
}
private async sendMarkdownNow(
input: AlertNotifyInput,
eventKeys: WecomPushCondition[],
): Promise<boolean> {
const envLabel = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || 'local').trim();
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const content = [
`## [${input.level}] ${escapeMd(input.title)}`,
`> 环境:<font color="comment">${escapeMd(envLabel)}</font>`,
`> 类别:<font color="comment">${escapeMd(input.category)}</font>`,
`> 时间:${escapeMd(now)}`,
'',
escapeMd(input.detail).slice(0, 3500),
].join('\n');
let sent = 0;
for (const eventKey of eventKeys) {
sent += await this.wecomPush.dispatchMarkdown(eventKey, content, { applyMention: false });
}
return sent > 0;
}
}
function resolveAlertEventKeys(input: AlertNotifyInput): WecomPushCondition[] {
if (input.eventKeys?.length) return [...new Set(input.eventKeys)];
if (input.category === 'pay') return ['alert.pay'];
if (input.category === 'redeem') return ['alert.redeem'];
if (input.category === 'settlement') return ['alert.settlement'];
if (input.category === 'ops') return ['alert.ops'];
return ['alert.system'];
}
/** 轻量转义,保留企微 markdown 的 <font> 标签可用 */
function escapeMd(s: string): string {
return s.replace(/([\\`*_[\]])/g, '\\$1');
}
@@ -0,0 +1,213 @@
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { AlertService } from './alert.service';
import {
ALERT_RATE_BUCKET_TTL_SEC,
ALERT_RATE_DEDUPE_TTL_SEC,
ALERT_THRESHOLDS,
} from './alert.constants';
export type PayAnomalyMeta = {
orderNo?: string;
userId?: string | number | bigint;
reason?: string;
};
export type RedeemAnomalyMeta = {
storeId?: string | number | bigint;
userId?: string | number | bigint;
recordId?: string | number | bigint;
reason?: string;
};
@Injectable()
export class PayRedeemAnomalyService {
private readonly logger = new Logger(PayRedeemAnomalyService.name);
constructor(
private readonly redis: RedisService,
private readonly alert: AlertService,
) {}
onPayAttempt(amountYuan: number, meta: PayAnomalyMeta = {}): void {
this.checkPayAmount(amountYuan, meta);
void this.bumpAndMaybeAlert(
'pay:attempt',
ALERT_THRESHOLDS.payAttemptPerMinute,
'P0',
'pay',
'支付频率异常',
`1 分钟内支付尝试超过 ${ALERT_THRESHOLDS.payAttemptPerMinute}`,
meta,
);
}
onPaySuccess(amountYuan: number, meta: PayAnomalyMeta = {}): void {
this.checkPayAmount(amountYuan, meta);
}
onPayFail(reason: string, meta: PayAnomalyMeta = {}): void {
void this.bumpAndMaybeAlert(
'pay:fail',
ALERT_THRESHOLDS.payFailPerMinute,
'P0',
'pay',
'支付失败频率异常',
`1 分钟内支付失败超过 ${ALERT_THRESHOLDS.payFailPerMinute} 次;最近原因:${reason}`,
{ ...meta, reason },
);
}
onRedeemAttempt(amountYuan: number, meta: RedeemAnomalyMeta = {}): void {
this.checkRedeemAmount(amountYuan, meta);
void this.bumpAndMaybeAlert(
'redeem:attempt',
ALERT_THRESHOLDS.redeemAttemptPerMinute,
'P0',
'redeem',
'核销频率异常',
`1 分钟内核销尝试超过 ${ALERT_THRESHOLDS.redeemAttemptPerMinute}`,
meta,
);
}
onRedeemFail(reason: string, meta: RedeemAnomalyMeta = {}): void {
void this.bumpAndMaybeAlert(
'redeem:fail',
ALERT_THRESHOLDS.redeemFailPerMinute,
'P0',
'redeem',
'核销失败频率异常',
`1 分钟内核销失败超过 ${ALERT_THRESHOLDS.redeemFailPerMinute} 次;最近原因:${reason}`,
{ ...meta, reason },
);
}
/** 弱网阈值 / 补核销待办等业务信号 */
notifyRedeemOps(title: string, detail: string, dedupeKey: string): void {
this.alert.notify({
level: 'P1',
category: 'redeem',
title,
detail,
dedupeKey,
});
}
private checkPayAmount(amountYuan: number, meta: PayAnomalyMeta): void {
if (!Number.isFinite(amountYuan)) return;
if (amountYuan < ALERT_THRESHOLDS.payAmountMinYuan) {
this.alert.notify({
level: 'P1',
category: 'pay',
title: '支付金额过低',
detail: formatPayDetail(
`支付金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.payAmountMinYuan}`,
meta,
),
dedupeKey: `pay_amount_low|${meta.orderNo ?? amountYuan}`,
});
} else if (amountYuan > ALERT_THRESHOLDS.payAmountMaxYuan) {
this.alert.notify({
level: 'P1',
category: 'pay',
title: '支付金额过高',
detail: formatPayDetail(
`支付金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.payAmountMaxYuan}`,
meta,
),
dedupeKey: `pay_amount_high|${meta.orderNo ?? amountYuan}`,
});
}
}
private checkRedeemAmount(amountYuan: number, meta: RedeemAnomalyMeta): void {
if (!Number.isFinite(amountYuan)) return;
if (amountYuan < ALERT_THRESHOLDS.redeemAmountMinYuan) {
this.alert.notify({
level: 'P1',
category: 'redeem',
title: '核销金额过低',
detail: formatRedeemDetail(
`核销金额 ${amountYuan} 元 < ${ALERT_THRESHOLDS.redeemAmountMinYuan}`,
meta,
),
dedupeKey: `redeem_amount_low|${meta.recordId ?? meta.storeId ?? amountYuan}`,
});
} else if (amountYuan > ALERT_THRESHOLDS.redeemAmountMaxYuan) {
this.alert.notify({
level: 'P1',
category: 'redeem',
title: '核销金额过高',
detail: formatRedeemDetail(
`核销金额 ${amountYuan} 元 > ${ALERT_THRESHOLDS.redeemAmountMaxYuan}`,
meta,
),
dedupeKey: `redeem_amount_high|${meta.recordId ?? meta.storeId ?? amountYuan}`,
});
}
}
private async bumpAndMaybeAlert(
counterKind: string,
threshold: number,
level: 'P0' | 'P1' | 'P2',
category: string,
title: string,
baseDetail: string,
meta: PayAnomalyMeta & RedeemAnomalyMeta,
): Promise<void> {
try {
const bucket = Math.floor(Date.now() / 60_000);
const key = `alert:cnt:${counterKind}:${bucket}`;
const count = await this.redis.incr(key, ALERT_RATE_BUCKET_TTL_SEC);
if (count <= threshold) return;
const extra = [
`当前分钟计数:${count}`,
meta.orderNo ? `订单:${meta.orderNo}` : null,
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
this.alert.notify({
level,
category,
title,
detail: `${baseDetail}\n${extra}`,
dedupeKey: `${counterKind}|${bucket}`,
dedupeTtlSec: ALERT_RATE_DEDUPE_TTL_SEC,
});
} catch (e) {
this.logger.warn(
`anomaly counter failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
function formatPayDetail(line: string, meta: PayAnomalyMeta): string {
return [
line,
meta.orderNo ? `订单:${meta.orderNo}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatRedeemDetail(line: string, meta: RedeemAnomalyMeta): string {
return [
line,
meta.storeId != null ? `门店:${String(meta.storeId)}` : null,
meta.userId != null ? `用户:${String(meta.userId)}` : null,
meta.recordId != null ? `记录:${String(meta.recordId)}` : null,
meta.reason ? `原因:${meta.reason}` : null,
]
.filter(Boolean)
.join('\n');
}
@@ -0,0 +1,81 @@
import type { CommonEvent } from '@prisma/client';
type OrderLike = {
productName?: string;
productSpec?: string;
quantity?: number;
listUnitPrice?: unknown;
payAmount?: unknown;
payStatus?: string;
payExternalNo?: string | null;
paidAt?: Date | string | null;
imageResource?: { url?: string } | null;
};
export function mapOrderItemCompat(order: OrderLike) {
return {
productName: order.productName ?? '',
productSpec: order.productSpec ?? '',
productImage: order.imageResource?.url ?? '',
unitPrice: Number(order.listUnitPrice ?? 0),
quantity: order.quantity ?? 1,
};
}
export function mapOrderCompat<T extends OrderLike & {
orderType?: string | null;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
proxyPartnerAccountId?: bigint | number | string | null;
}>(order: T) {
const payStatus = order.payStatus ?? 'UNPAID';
const isProxyOrder = order.orderType === 'PROXY';
return {
...order,
isProxyOrder,
proxyPartnerName: order.proxyPartnerName ?? null,
proxyPartnerPhone: order.proxyPartnerPhone ?? null,
items: [mapOrderItemCompat(order)],
payment: {
status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
externalNo: order.payExternalNo ?? null,
paidAt: order.paidAt ?? null,
},
};
}
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
return {
...store,
coverUrl: store.coverResource?.url ?? null,
};
}
export function mapStatusLogCompat(events: CommonEvent[]) {
return events.map((e) => ({
fromStatus: e.param1,
toStatus: e.param2 ?? '',
operator: e.param3,
remark: e.remark,
createdAt: e.createdAt,
}));
}
export function mapBenefitLedgerCompat(
event: CommonEvent,
user?: { userNo?: string | null } | null,
coupon?: { couponNo?: string } | null,
) {
return {
id: event.id,
type: event.param1,
amount: event.amount1 != null ? Number(event.amount1) : 0,
balanceAfter: event.amount2 != null ? Number(event.amount2) : 0,
remark: event.remark,
createdAt: event.createdAt,
userId: event.actorId,
couponId: event.param2 ? BigInt(event.param2) : null,
user: user ? { userNo: user.userNo } : undefined,
coupon: coupon ? { couponNo: coupon.couponNo } : undefined,
};
}
@@ -0,0 +1,20 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
const SALT_LEN = 16;
const KEY_LEN = 64;
export function hashPassword(password: string): string {
const salt = randomBytes(SALT_LEN);
const hash = scryptSync(password, salt, KEY_LEN);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = scryptSync(password, salt, expected.length);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from '../guards/jwt-auth.guard';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
return ctx.switchToHttp().getRequest().user;
},
);
export function serializeBigInt<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
);
}
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import type { PartnerPermissionKey } from '@dukang/shared-types';
export const PARTNER_PERMISSIONS_KEY = 'partner_permissions';
export const RequirePartnerPermissions = (...permissions: PartnerPermissionKey[]) =>
SetMetadata(PARTNER_PERMISSIONS_KEY, permissions);
@@ -0,0 +1,117 @@
import type { Prisma } from '@prisma/client';
type BenefitLedgerType = 'GRANT' | 'REDEEM' | 'REFUND_VOID' | 'ADJUST';
export function buildBenefitLedgerEvent(data: {
userId: bigint;
couponId: bigint;
type: BenefitLedgerType;
amount: number;
balanceAfter: number;
refType: string;
refId?: bigint;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'BENEFIT_LEDGER',
refType: data.refType,
refId: data.refId ?? data.couponId,
actorType: 'USER',
actorId: data.userId,
param1: data.type,
param1Desc: 'ledger_type',
param2: data.couponId.toString(),
param2Desc: 'coupon_id',
amount1: data.amount,
amount2: data.balanceAfter,
remark: data.remark,
};
}
export function buildOrderStatusEvent(data: {
orderId: bigint;
fromStatus: string;
toStatus: string;
operator: string;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: data.orderId,
actorType: 'SYSTEM',
param1: data.fromStatus,
param1Desc: 'from_status',
param2: data.toStatus,
param2Desc: 'to_status',
param3: data.operator,
param3Desc: 'operator',
remark: data.remark,
};
}
export function benefitLedgerWhere(userId?: bigint, couponId?: bigint): Prisma.CommonEventWhereInput {
return {
eventType: 'BENEFIT_LEDGER',
...(userId ? { actorType: 'USER', actorId: userId } : {}),
...(couponId ? { param2: couponId.toString() } : {}),
};
}
export function orderStatusLogWhere(orderId: bigint): Prisma.CommonEventWhereInput {
return {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: orderId,
};
}
export function buildHqOperationEvent(data: {
hqAccountId: bigint;
action: string;
refType: string;
refId: bigint;
status?: string;
detail?: Record<string, unknown>;
remark?: string;
}): Prisma.CommonEventCreateInput {
return {
eventType: 'HQ_OPERATION',
refType: data.refType,
refId: data.refId,
actorType: 'HQ',
actorId: data.hqAccountId,
status: data.status,
param1: data.action,
param1Desc: 'action',
param2: data.refType,
param2Desc: 'target_type',
param3: data.refId.toString(),
param3Desc: 'target_id',
remark: data.remark,
extraJson: data.detail as Prisma.InputJsonValue,
};
}
export function hqOperationLogWhere(filters?: {
hqAccountId?: bigint;
action?: string;
refType?: string;
from?: Date;
to?: Date;
}): Prisma.CommonEventWhereInput {
return {
eventType: 'HQ_OPERATION',
...(filters?.hqAccountId ? { actorType: 'HQ' as const, actorId: filters.hqAccountId } : {}),
...(filters?.action ? { param1: filters.action } : {}),
...(filters?.refType ? { param2: filters.refType } : {}),
...(filters?.from || filters?.to
? {
createdAt: {
...(filters.from ? { gte: filters.from } : {}),
...(filters.to ? { lte: filters.to } : {}),
},
}
: {}),
};
}
@@ -0,0 +1,109 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AlertService } from '../alert/alert.service';
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
switch (exception.code) {
case 'P2002':
return '数据冲突,请刷新后重试';
case 'P2003':
return '关联数据不存在,请检查门店/权益配置';
case 'P2021':
case 'P2022':
return '数据库表结构未同步,请在服务器执行 prisma db push';
case 'P2025':
return '记录不存在或已被删除';
default:
return exception.message;
}
}
@Catch()
@Injectable()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly alert: AlertService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest<{ method?: string; url?: string }>();
const method = request?.method ?? '?';
const path = request?.url ?? '?';
if (exception instanceof HttpException) {
const status = exception.getStatus();
const res = exception.getResponse();
const message =
typeof res === 'string'
? res
: (res as { message?: string | string[] }).message || exception.message;
const msgText = Array.isArray(message) ? message.join(', ') : message;
if (status >= 500) {
this.alert.notify({
level: 'P0',
category: 'api_error',
title: `API ${status}`,
detail: `${method} ${path}\n${msgText}`,
dedupeKey: `http_${status}|${method}|${path}|${String(msgText).slice(0, 80)}`,
dedupeTtlSec: 120,
});
}
response.status(status).json({
code: status,
message: msgText,
data: null,
});
return;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
console.error(exception);
this.alert.notify({
level: 'P0',
category: 'api_error',
title: `Prisma ${exception.code}`,
detail: `${method} ${path}\n${exception.message}`,
dedupeKey: `prisma|${exception.code}|${method}|${path}`,
dedupeTtlSec: 120,
});
response.status(HttpStatus.BAD_REQUEST).json({
code: 400,
message: prismaErrorMessage(exception),
data: null,
});
return;
}
if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) {
response.status(HttpStatus.BAD_REQUEST).json({
code: 400,
message: 'ID 格式无效',
data: null,
});
return;
}
console.error(exception);
const errMsg = exception instanceof Error ? exception.message : 'Internal server error';
this.alert.notify({
level: 'P0',
category: 'api_error',
title: '未捕获异常 500',
detail: `${method} ${path}\n${errMsg}`,
dedupeKey: `uncaught|${method}|${path}|${errMsg.slice(0, 80)}`,
dedupeTtlSec: 120,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: 500,
message: errMsg,
data: null,
});
}
}
@@ -0,0 +1,24 @@
import type { Request } from 'express';
function normalizeIp(raw?: string | null): string | null {
if (!raw) return null;
const ip = raw.replace(/^::ffff:/, '').trim();
if (!ip || ip === '::1') return null;
return ip;
}
/** 从反向代理 / Socket 提取客户端 IP */
export function extractClientIp(req: Request): string | null {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return normalizeIp(forwarded.split(',')[0]);
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return normalizeIp(forwarded[0]?.split(',')[0]);
}
const realIp = req.headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.length > 0) {
return normalizeIp(realIp);
}
return normalizeIp(req.ip ?? req.socket?.remoteAddress ?? null);
}
@@ -0,0 +1,44 @@
import type { ClientGpsLocation, OrderClientLocationSnapshot } from './geo.types';
import type { IpRegion } from './geo.types';
import { extractClientIp } from './client-ip.util';
import type { Request } from 'express';
function parseGpsLocation(raw: unknown): ClientGpsLocation | null {
if (!raw || typeof raw !== 'object') return null;
const input = raw as Record<string, unknown>;
const latitude = Number(input.latitude);
const longitude = Number(input.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
return {
province: input.province != null ? String(input.province).trim() || null : null,
city: input.city != null ? String(input.city).trim() || null : null,
district: input.district != null ? String(input.district).trim() || null : null,
latitude,
longitude,
address: input.address != null ? String(input.address).trim() || null : null,
};
}
export function buildOrderClientLocationSnapshot(
req: Request,
ipGeo: IpRegion,
clientLocationRaw?: unknown,
): OrderClientLocationSnapshot {
const clientIp = extractClientIp(req);
const gps = parseGpsLocation(clientLocationRaw);
return {
clientIp,
ipProvince: ipGeo.province,
ipCity: ipGeo.city,
ipDistrict: ipGeo.district,
gpsProvince: gps?.province ?? null,
gpsCity: gps?.city ?? null,
gpsDistrict: gps?.district ?? null,
gpsLatitude: gps?.latitude ?? null,
gpsLongitude: gps?.longitude ?? null,
gpsAddress: gps?.address ?? null,
};
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { IpGeoService } from './ip-geo.service';
@Global()
@Module({
providers: [IpGeoService],
exports: [IpGeoService],
})
export class GeoModule {}
@@ -0,0 +1,27 @@
export type IpRegion = {
province: string | null;
city: string | null;
district: string | null;
};
export type ClientGpsLocation = {
province?: string | null;
city?: string | null;
district?: string | null;
latitude: number;
longitude: number;
address?: string | null;
};
export type OrderClientLocationSnapshot = {
clientIp: string | null;
ipProvince: string | null;
ipCity: string | null;
ipDistrict: string | null;
gpsProvince: string | null;
gpsCity: string | null;
gpsDistrict: string | null;
gpsLatitude: number | null;
gpsLongitude: number | null;
gpsAddress: string | null;
};
@@ -0,0 +1,49 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import IP2Region from 'ip2region';
import type { IpRegion } from './geo.types';
function cleanRegionName(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed || trimmed === '0' || trimmed === '内网IP') return null;
return trimmed;
}
@Injectable()
export class IpGeoService implements OnModuleInit {
private readonly logger = new Logger(IpGeoService.name);
private query: IP2Region | null = null;
onModuleInit() {
try {
this.query = new IP2Region();
} catch (error) {
this.logger.warn(`ip2region 初始化失败: ${error instanceof Error ? error.message : error}`);
}
}
resolve(ip: string | null): IpRegion {
if (!ip || !this.query) {
return { province: null, city: null, district: null };
}
if (ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
return { province: null, city: null, district: null };
}
try {
const result = this.query.search(ip) as {
province?: string;
city?: string;
region?: string;
};
return {
province: cleanRegionName(result?.province),
city: cleanRegionName(result?.city),
district: cleanRegionName(result?.region),
};
} catch (error) {
this.logger.debug(`IP 解析失败 ${ip}: ${error instanceof Error ? error.message : error}`);
return { province: null, city: null, district: null };
}
}
}
@@ -0,0 +1,26 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ClientApp } from '@dukang/shared-types';
import { JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ok = super.canActivate(context);
if (!ok) return false;
const req = context.switchToHttp().getRequest();
const clientApp = req.headers['x-client-app'] as ClientApp;
if (clientApp !== ClientApp.HQ_WEB) {
throw new UnauthorizedException('Invalid client app for admin');
}
if (req.user?.actorType !== 'HQ') {
throw new UnauthorizedException('HQ access required');
}
return true;
}
}
@@ -0,0 +1,103 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
export const HQ_PERMISSIONS_KEY = 'hq:permissions';
export const RequireHqPermissions = (...keys: string[]) =>
SetMetadata(HQ_PERMISSIONS_KEY, keys);
/** 任意一项系统设置分组权限即可访问系统设置接口 */
export const RequireAnySystemSettings = () =>
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
@Injectable()
export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用');
}
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
select: { permissionKey: true },
});
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
...userKeys,
]);
}
const roleRows = await this.prisma.hqRolePermission.findMany({
where: { adminRole: account.adminRole },
select: { permissionKey: true },
});
const roleKeys =
roleRows.length > 0
? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
}
}
@Injectable()
export class HqPermissionGuard implements CanActivate {
constructor(
private readonly resolver: HqPermissionsResolver,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
req.hqPermissionKeys = keys;
const required =
this.reflector.getAllAndOverride<string[]>(HQ_PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]) ?? [];
if (!required.length) return true;
if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限');
}
return true;
}
if (!required.some((k) => keys.includes(k as HqPermissionKey))) {
throw new ForbiddenException('权限不足');
}
return true;
}
}
@@ -0,0 +1,56 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
export interface AuthUser {
actorType: string;
actorId: bigint;
clientApp: ClientApp;
sub: string;
phoneVerified: boolean;
/** Selected store after POST /shop/auth/select-store */
storeId?: bigint;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(protected readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const auth = req.headers.authorization as string | undefined;
if (!auth?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing token');
}
try {
const payload = this.jwtService.verify(auth.slice(7));
const clientApp = req.headers['x-client-app'] as ClientApp;
if (!clientApp || payload.clientApp !== clientApp) {
throw new UnauthorizedException('Invalid client app');
}
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
if (payload.actorType !== expectedActor) {
throw new UnauthorizedException('Actor mismatch');
}
req.user = {
actorType: payload.actorType,
actorId: BigInt(payload.actorId),
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
return true;
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid token');
}
}
}
@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
import type { AuthUser } from './jwt-auth.guard';
/** C 端 H5 / 小程序同属 USER,目录白名单只认 actorId→手机号,允许端间 token 兼容 */
const USER_CLIENT_APPS = new Set<ClientApp>([ClientApp.USER_MINI, ClientApp.USER_H5]);
function clientAppCompatible(headerApp: ClientApp | undefined, payloadApp: unknown): boolean {
if (!headerApp || payloadApp == null) return false;
if (headerApp === payloadApp) return true;
return USER_CLIENT_APPS.has(headerApp) && USER_CLIENT_APPS.has(payloadApp as ClientApp);
}
@Injectable()
export class OptionalJwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const auth = req.headers.authorization as string | undefined;
if (!auth?.startsWith('Bearer ')) {
return true;
}
try {
const payload = this.jwtService.verify(auth.slice(7));
const clientApp = req.headers['x-client-app'] as ClientApp;
if (!clientAppCompatible(clientApp, payload.clientApp)) return true;
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
if (payload.actorType !== expectedActor) return true;
req.user = {
actorType: payload.actorType,
actorId: BigInt(payload.actorId),
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
} catch {
/* ignore invalid token */
}
return true;
}
}
@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { PartnerPermissionKey } from '@dukang/shared-types';
import { PARTNER_PERMISSIONS_KEY } from '../decorators/partner-permission.decorator';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPermissionGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
await this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary === 1) return true;
const required = this.reflector.getAllAndOverride<PartnerPermissionKey[]>(
PARTNER_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const perms = Array.isArray(account.permissions)
? (account.permissions as string[])
: [];
if (required.some((p) => perms.includes(p))) return true;
throw new ForbiddenException('当前子账号无此操作权限');
}
}
@@ -0,0 +1,32 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import { AuthUser, JwtAuthGuard } from './jwt-auth.guard';
@Injectable()
export class PartnerPrimaryGuard implements CanActivate {
constructor(
private readonly jwtAuthGuard: JwtAuthGuard,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
this.jwtAuthGuard.canActivate(context);
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser;
if (user.actorType !== 'PARTNER') {
throw new ForbiddenException('仅合伙人主账号可操作');
}
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary !== 1) {
throw new ForbiddenException('仅主账号可操作');
}
return true;
}
}
@@ -0,0 +1,33 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
@Injectable()
export class PhoneVerifiedGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'USER') {
throw new ForbiddenException('请先验证手机号');
}
const row = await this.prisma.user.findUnique({
where: { id: user.actorId },
select: { phoneVerifiedAt: true, mergedIntoUserId: true, status: true },
});
if (!row || row.status !== 1 || row.mergedIntoUserId) {
throw new ForbiddenException('账号状态异常,请重新进入');
}
if (!row.phoneVerifiedAt) {
throw new ForbiddenException('请先验证手机号');
}
return true;
}
}
@@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
/** 门店主账号专用(提现等资金操作) */
@Injectable()
export class ShopPrimaryGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'STORE') {
throw new ForbiddenException('仅门店主账号可操作');
}
const account = await this.prisma.storeAccount.findUnique({
where: { id: user.actorId },
select: { isPrimary: true },
});
if (!account || account.isPrimary !== 1) {
throw new ForbiddenException('仅门店主账号可申请提现');
}
return true;
}
}
@@ -0,0 +1,15 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { AuthUser } from './jwt-auth.guard';
/** Shop business APIs require JWT claim storeId (after select-store). */
@Injectable()
export class ShopStoreGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user?.storeId) {
throw new ForbiddenException('请先选择门店');
}
return true;
}
}
@@ -0,0 +1,27 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from '../guards/jwt-auth.guard';
@Injectable()
export class StoreMembershipService {
constructor(private readonly prisma: PrismaService) {}
async assertStoreMembership(accountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUnique({
where: {
storeAccountId_storeId: { storeAccountId: accountId, storeId },
},
});
if (!binding) {
throw new ForbiddenException('无权访问该门店');
}
return binding;
}
requireShopStoreId(user: AuthUser): bigint {
if (!user.storeId) {
throw new ForbiddenException('请先选择门店');
}
return user.storeId;
}
}
@@ -0,0 +1,29 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from './jwt-auth.guard';
@Injectable()
export class SuperAdminGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const account = await this.prisma.hqAccount.findUnique({
where: { id: user.actorId },
select: { adminRole: true, status: true },
});
if (!account || account.status !== 'ACTIVE' || account.adminRole !== 'SUPER_ADMIN') {
throw new ForbiddenException('需要超级管理员权限');
}
return true;
}
}
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import { buildHqOperationEvent } from '../event/event.helpers';
export type LogHqOperationInput = {
hqAccountId: bigint;
action: string;
refType: string;
refId: bigint;
status?: string;
detail?: Record<string, unknown>;
remark?: string;
};
@Injectable()
export class HqOperationLogService {
constructor(private readonly prisma: PrismaService) {}
async log(input: LogHqOperationInput): Promise<void> {
await this.prisma.commonEvent.create({
data: buildHqOperationEvent(input),
});
}
logSafe(input: LogHqOperationInput): void {
void this.log(input).catch((err) => {
console.error('[HqOperationLog] write failed', err);
});
}
}
@@ -0,0 +1,253 @@
/** HQ 后台写操作审计 action(写入 common_event.param1 */
export const HqOperationAction = {
CITY_CREATE: 'CITY_CREATE',
CITY_UPDATE: 'CITY_UPDATE',
CITY_DELETE: 'CITY_DELETE',
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
USER_DELETE: 'USER_DELETE',
USER_BATCH_DELETE: 'USER_BATCH_DELETE',
ORDER_SHIP: 'ORDER_SHIP',
ORDER_STATUS_DEBUG: 'ORDER_STATUS_DEBUG',
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
ORDER_DELETE: 'ORDER_DELETE',
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_STATUS: 'STORE_STATUS',
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
PRODUCT_CREATE: 'PRODUCT_CREATE',
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
PRODUCT_DELETE: 'PRODUCT_DELETE',
PRODUCT_TEMPLATE_CREATE: 'PRODUCT_TEMPLATE_CREATE',
PRODUCT_TEMPLATE_UPDATE: 'PRODUCT_TEMPLATE_UPDATE',
BENEFIT_COUPON_VOID: 'BENEFIT_COUPON_VOID',
BENEFIT_COUPON_GRANT: 'BENEFIT_COUPON_GRANT',
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
TICKET_APPROVE: 'TICKET_APPROVE',
TICKET_REJECT: 'TICKET_REJECT',
TICKET_CREATE: 'TICKET_CREATE',
SUPPORT_TICKET_CREATE: 'SUPPORT_TICKET_CREATE',
SUPPORT_TICKET_APPROVE: 'SUPPORT_TICKET_APPROVE',
SUPPORT_TICKET_REJECT: 'SUPPORT_TICKET_REJECT',
SUPPORT_TICKET_START_TESTING: 'SUPPORT_TICKET_START_TESTING',
SUPPORT_TICKET_PASS: 'SUPPORT_TICKET_PASS',
INVOICE_CREATE: 'INVOICE_CREATE',
INVOICE_ISSUE: 'INVOICE_ISSUE',
INVOICE_REJECT: 'INVOICE_REJECT',
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
STORE_BILL_BATCH_CONFIRM: 'STORE_BILL_BATCH_CONFIRM',
STORE_WITHDRAW_APPROVE: 'STORE_WITHDRAW_APPROVE',
STORE_WITHDRAW_REJECT: 'STORE_WITHDRAW_REJECT',
PARTNER_BILL_GENERATE: 'PARTNER_BILL_GENERATE',
PARTNER_BILL_SEND: 'PARTNER_BILL_SEND',
PARTNER_BILL_BATCH_SEND: 'PARTNER_BILL_BATCH_SEND',
PARTNER_BILL_CONFIRM: 'PARTNER_BILL_CONFIRM',
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
PARTNER_BILL_BATCH_MARK_PAID: 'PARTNER_BILL_BATCH_MARK_PAID',
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
WECOM_BOT_CREATE: 'WECOM_BOT_CREATE',
WECOM_BOT_UPDATE: 'WECOM_BOT_UPDATE',
WECOM_BOT_DELETE: 'WECOM_BOT_DELETE',
WECOM_BOT_RELOAD: 'WECOM_BOT_RELOAD',
WECOM_MESSAGE_PUSH_CREATE: 'WECOM_MESSAGE_PUSH_CREATE',
WECOM_MESSAGE_PUSH_UPDATE: 'WECOM_MESSAGE_PUSH_UPDATE',
WECOM_MESSAGE_PUSH_DELETE: 'WECOM_MESSAGE_PUSH_DELETE',
WECOM_MESSAGE_PUSH_TEST: 'WECOM_MESSAGE_PUSH_TEST',
LLM_CONFIG_CREATE: 'LLM_CONFIG_CREATE',
LLM_CONFIG_UPDATE: 'LLM_CONFIG_UPDATE',
LLM_CONFIG_DELETE: 'LLM_CONFIG_DELETE',
LLM_CONFIG_TEST: 'LLM_CONFIG_TEST',
KNOWLEDGE_BASE_CREATE: 'KNOWLEDGE_BASE_CREATE',
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
DEV_PLAN_TASK_CREATE: 'DEV_PLAN_TASK_CREATE',
DEV_PLAN_TASK_UPDATE: 'DEV_PLAN_TASK_UPDATE',
DEV_PLAN_TASK_DELETE: 'DEV_PLAN_TASK_DELETE',
DEV_PLAN_TASK_DISPATCH: 'DEV_PLAN_TASK_DISPATCH',
DEV_PLAN_VERSION_CREATE: 'DEV_PLAN_VERSION_CREATE',
DEV_PLAN_VERSION_UPDATE: 'DEV_PLAN_VERSION_UPDATE',
DEV_PLAN_VERSION_DELETE: 'DEV_PLAN_VERSION_DELETE',
DEV_PLAN_VERSION_LINK_TASKS: 'DEV_PLAN_VERSION_LINK_TASKS',
DEV_PLAN_VERSION_ADD_TASKS: 'DEV_PLAN_VERSION_ADD_TASKS',
DEV_PLAN_SETTINGS_UPDATE: 'DEV_PLAN_SETTINGS_UPDATE',
DEV_PLAN_DISPATCH_TEST: 'DEV_PLAN_DISPATCH_TEST',
SUPPORT_TICKET_REVIEW: 'SUPPORT_TICKET_REVIEW',
SUPPORT_TICKET_BATCH_REVIEW: 'SUPPORT_TICKET_BATCH_REVIEW',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.CITY_CREATE]: '新增开城城市',
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
[HqOperationAction.CITY_DELETE]: '删除开城城市',
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
[HqOperationAction.USER_DELETE]: '删除用户',
[HqOperationAction.USER_BATCH_DELETE]: '批量删除用户',
[HqOperationAction.ORDER_SHIP]: '订单发货',
[HqOperationAction.ORDER_STATUS_DEBUG]: '订单状态调试',
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
[HqOperationAction.ORDER_DELETE]: '删除订单',
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
[HqOperationAction.PRODUCT_TEMPLATE_CREATE]: '新增详情模板',
[HqOperationAction.PRODUCT_TEMPLATE_UPDATE]: '编辑详情模板',
[HqOperationAction.BENEFIT_COUPON_VOID]: '作废权益券',
[HqOperationAction.BENEFIT_COUPON_GRANT]: '手动发放权益',
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
[HqOperationAction.TICKET_APPROVE]: '工单通过',
[HqOperationAction.TICKET_REJECT]: '工单驳回',
[HqOperationAction.TICKET_CREATE]: '创建工单',
[HqOperationAction.SUPPORT_TICKET_CREATE]: '创建技术支持工单',
[HqOperationAction.SUPPORT_TICKET_APPROVE]: '技术支持评审通过',
[HqOperationAction.SUPPORT_TICKET_REJECT]: '技术支持评审驳回',
[HqOperationAction.SUPPORT_TICKET_START_TESTING]: '技术支持转入测试',
[HqOperationAction.SUPPORT_TICKET_PASS]: '技术支持测试通过',
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
[HqOperationAction.STORE_BILL_BATCH_CONFIRM]: '批量门店对账单打款',
[HqOperationAction.STORE_WITHDRAW_APPROVE]: '门店提现审核通过',
[HqOperationAction.STORE_WITHDRAW_REJECT]: '门店提现驳回',
[HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单',
[HqOperationAction.PARTNER_BILL_SEND]: '发送合伙人账单',
[HqOperationAction.PARTNER_BILL_BATCH_SEND]: '批量发送合伙人账单',
[HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单',
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID]: '批量合伙人账单结算',
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
[HqOperationAction.WECOM_BOT_CREATE]: '创建企微机器人',
[HqOperationAction.WECOM_BOT_UPDATE]: '编辑企微机器人',
[HqOperationAction.WECOM_BOT_DELETE]: '删除企微机器人',
[HqOperationAction.WECOM_BOT_RELOAD]: '重载企微机器人连接',
[HqOperationAction.WECOM_MESSAGE_PUSH_CREATE]: '创建企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE]: '编辑企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_DELETE]: '删除企微消息推送',
[HqOperationAction.WECOM_MESSAGE_PUSH_TEST]: '测试企微消息推送',
[HqOperationAction.LLM_CONFIG_CREATE]: '创建语言模型配置',
[HqOperationAction.LLM_CONFIG_UPDATE]: '更新语言模型配置',
[HqOperationAction.LLM_CONFIG_DELETE]: '删除语言模型配置',
[HqOperationAction.LLM_CONFIG_TEST]: '测试语言模型配置',
[HqOperationAction.KNOWLEDGE_BASE_CREATE]: '创建知识库',
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
[HqOperationAction.DEV_PLAN_TASK_CREATE]: '创建开发计划任务',
[HqOperationAction.DEV_PLAN_TASK_UPDATE]: '更新开发计划任务',
[HqOperationAction.DEV_PLAN_TASK_DELETE]: '删除开发计划任务',
[HqOperationAction.DEV_PLAN_TASK_DISPATCH]: '派发开发计划任务',
[HqOperationAction.DEV_PLAN_VERSION_CREATE]: '创建开发计划版本',
[HqOperationAction.DEV_PLAN_VERSION_UPDATE]: '更新开发计划版本',
[HqOperationAction.DEV_PLAN_VERSION_DELETE]: '删除开发计划版本',
[HqOperationAction.DEV_PLAN_VERSION_LINK_TASKS]: '关联版本任务',
[HqOperationAction.DEV_PLAN_VERSION_ADD_TASKS]: '追加关联版本任务',
[HqOperationAction.DEV_PLAN_SETTINGS_UPDATE]: '更新开发计划设置',
[HqOperationAction.DEV_PLAN_DISPATCH_TEST]: '测试任务派发助手',
[HqOperationAction.SUPPORT_TICKET_REVIEW]: '技术支持工单审批',
[HqOperationAction.SUPPORT_TICKET_BATCH_REVIEW]: '技术支持批量审批',
STORE_PAYOUT: '门店打款确认',
};
export function resolveHqOperationLabel(action: string | null | undefined, refType?: string | null): string {
if (action && HQ_OPERATION_ACTION_LABELS[action]) {
return HQ_OPERATION_ACTION_LABELS[action];
}
if (refType && HQ_OPERATION_ACTION_LABELS[refType]) {
return HQ_OPERATION_ACTION_LABELS[refType];
}
return action || refType || 'HQ 操作';
}
@@ -0,0 +1,21 @@
import { SetMetadata } from '@nestjs/common';
import type { HqOperationActionCode } from './hq-operation.constants';
export const HQ_OPERATION_KEY = 'hq_operation';
export type HqOperationMeta = {
action: HqOperationActionCode | string;
refType: string;
/** 从路由 params 取 refId */
refIdParam?: string;
/** 从响应体字段取 refId,默认 id */
refIdField?: string;
/** 批量操作无单一 refId 时用 0 */
batch?: boolean;
/** 记录请求体到 extraJson */
includeBody?: boolean;
/** 记录响应体到 extraJson(截断) */
includeResponse?: boolean;
};
export const HqOperation = (meta: HqOperationMeta) => SetMetadata(HQ_OPERATION_KEY, meta);
@@ -0,0 +1,113 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, catchError, tap, throwError } from 'rxjs';
import type { AuthUser } from '../guards/jwt-auth.guard';
import type { RequestWithId } from '../logging/request-id.middleware';
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
import { HqOperationLogService } from './hq-operation-log.service';
function pickRefId(value: unknown): bigint | null {
if (value == null || value === '') return null;
try {
return BigInt(String(value));
} catch {
return null;
}
}
function sanitizeBody(body: unknown): unknown {
if (!body || typeof body !== 'object') return body;
const copy = { ...(body as Record<string, unknown>) };
for (const key of Object.keys(copy)) {
if (/password|secret|token/i.test(key)) {
copy[key] = '***';
}
}
return copy;
}
function summarizeResponse(data: unknown): unknown {
if (data == null) return null;
if (typeof data !== 'object') return data;
const obj = data as Record<string, unknown>;
const summary: Record<string, unknown> = {};
for (const key of ['id', 'ok', 'deleted', 'orderNo', 'userNo', 'redeemNo', 'message', 'status']) {
if (obj[key] !== undefined) summary[key] = obj[key];
}
if (Array.isArray(obj.items)) {
summary.itemCount = obj.items.length;
}
if (Array.isArray(obj.orderNos)) {
summary.orderNos = obj.orderNos;
}
return Object.keys(summary).length ? summary : obj;
}
@Injectable()
export class HqOperationInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly logService: HqOperationLogService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<HqOperationMeta | undefined>(
HQ_OPERATION_KEY,
context.getHandler(),
);
if (!meta) return next.handle();
const req = context.switchToHttp().getRequest<RequestWithId & { user?: AuthUser }>();
const user = req.user as AuthUser | undefined;
if (!user || user.actorType !== 'HQ') {
return next.handle();
}
const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
const refId = meta.batch
? 0n
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
?? pickRefId(
meta.refIdField
? (data as Record<string, unknown> | null)?.[meta.refIdField]
: (data as Record<string, unknown> | null)?.id,
)
?? 0n;
const detail: Record<string, unknown> = {
method: req.method,
path: req.originalUrl ?? req.url,
requestId: req.requestId,
};
if (meta.includeBody && req.body) {
detail.requestBody = sanitizeBody(req.body);
}
if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
detail.response = summarizeResponse(data);
}
if (errorMessage) detail.error = errorMessage;
this.logService.logSafe({
hqAccountId: user.actorId,
action: meta.action,
refType: meta.refType,
refId,
status,
detail,
});
};
return next.handle().pipe(
tap((data) => writeLog('SUCCESS', data)),
catchError((err: { message?: string }) => {
writeLog('FAILED', undefined, err?.message ?? '操作失败');
return throwError(() => err);
}),
);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { HqOperationLogService } from './hq-operation-log.service';
import { HqOperationInterceptor } from './hq-operation.interceptor';
@Module({
providers: [
HqOperationLogService,
HqOperationInterceptor,
{
provide: APP_INTERCEPTOR,
useClass: HqOperationInterceptor,
},
],
exports: [HqOperationLogService],
})
export class HqOperationModule {}
@@ -0,0 +1,20 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data) => ({
code: 0,
message: 'ok',
data: data ?? null,
})),
);
}
}
@@ -0,0 +1,55 @@
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import type { Response } from 'express';
import type { AuthUser } from '../guards/jwt-auth.guard';
import type { RequestWithId } from './request-id.middleware';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = Date.now();
const http = context.switchToHttp();
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
const res = http.getResponse<Response>();
return next.handle().pipe(
tap({
next: () => this.logLine(req, res.statusCode, Date.now() - started),
error: (err: { status?: number; message?: string }) => {
const status = err?.status ?? res.statusCode ?? 500;
this.logLine(req, status, Date.now() - started, err?.message);
},
}),
);
}
private logLine(
req: RequestWithId & { user?: AuthUser },
status: number,
latencyMs: number,
error?: string,
) {
const line = {
requestId: req.requestId,
method: req.method,
path: req.originalUrl || req.url,
status,
latencyMs,
clientApp: req.headers['x-client-app'],
actorType: req.user?.actorType,
actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
error: error?.slice(0, 200),
};
if (status >= 500) this.logger.error(JSON.stringify(line));
else if (status >= 400) this.logger.warn(JSON.stringify(line));
else this.logger.log(JSON.stringify(line));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RequestIdMiddleware } from './request-id.middleware';
import { LoggingInterceptor } from './logging.interceptor';
@Module({
providers: [RequestIdMiddleware, LoggingInterceptor],
exports: [RequestIdMiddleware, LoggingInterceptor],
})
export class LoggingModule {}
@@ -0,0 +1,21 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
export const REQUEST_ID_HEADER = 'x-request-id';
export type RequestWithId = Request & { requestId?: string };
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: RequestWithId, res: Response, next: NextFunction) {
const incoming = req.headers[REQUEST_ID_HEADER];
const requestId =
typeof incoming === 'string' && incoming.trim()
? incoming.trim().slice(0, 64)
: randomUUID();
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
next();
}
}
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import type { MockSmsCodeItem } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
const LIST_LIMIT = 50;
@Injectable()
export class MockSmsCodeService {
constructor(private readonly prisma: PrismaService) {}
async record(phone: string, scene: string, code: string) {
await this.prisma.mockSmsCode.create({
data: { phone, scene, code },
});
}
async listRecent(limit = LIST_LIMIT): Promise<MockSmsCodeItem[]> {
const rows = await this.prisma.mockSmsCode.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
});
return rows.map((row) => ({
id: String(row.id),
phone: row.phone,
scene: row.scene,
code: row.code,
createdAt: row.createdAt.toISOString(),
}));
}
}
@@ -0,0 +1,13 @@
import { BadRequestException } from '@nestjs/common';
export function parseBigIntParam(value: unknown, label = 'ID'): bigint {
const text = String(value ?? '').trim();
if (!/^\d+$/.test(text)) {
throw new BadRequestException(`${label}格式无效`);
}
try {
return BigInt(text);
} catch {
throw new BadRequestException(`${label}格式无效`);
}
}
@@ -0,0 +1,16 @@
import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: () => new Redis(process.env.REDIS_URL || 'redis://localhost:6379'),
},
RedisService,
],
exports: ['REDIS_CLIENT', RedisService],
})
export class RedisModule {}
@@ -0,0 +1,45 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
@Injectable()
export class RedisService {
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
get client() {
return this.redis;
}
async setJson(key: string, value: unknown, ttlSeconds?: number) {
const payload = JSON.stringify(value);
if (ttlSeconds) {
await this.redis.set(key, payload, 'EX', ttlSeconds);
} else {
await this.redis.set(key, payload);
}
}
async getJson<T>(key: string): Promise<T | null> {
const raw = await this.redis.get(key);
return raw ? (JSON.parse(raw) as T) : null;
}
async del(key: string) {
await this.redis.del(key);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
const count = await this.redis.incr(key);
if (ttlSeconds && count === 1) {
await this.redis.expire(key, ttlSeconds);
}
return count;
}
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
}
@@ -0,0 +1,55 @@
import { resolve } from 'path';
import { PrismaClient } from '@prisma/client';
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
function apiRoot() {
return resolve(__dirname, '..', '..');
}
export function resolveEnvFilePath() {
const isProduction = (process.env.NODE_ENV ?? 'development') === 'production';
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
}
function normalizeConfigValue(key: string, value: string): string {
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
return value;
}
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
export async function preloadSystemConfigEnv(): Promise<number> {
const prisma = new PrismaClient();
try {
const rows = await prisma.systemConfig.findMany();
for (const row of rows) {
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
}
}
return rows.length;
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
) {
console.warn('[config] system_config 表不存在,跳过 DB 预加载');
return 0;
}
throw error;
} finally {
await prisma.$disconnect();
}
}
export function applyEnvOverlay(values: Record<string, string>) {
for (const [key, value] of Object.entries(values)) {
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
process.env[key] = normalizeConfigValue(key, value);
}
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
import { SystemConfigService } from './system-config.service';
@Global()
@Module({
providers: [SystemConfigService, MockSmsCodeService],
exports: [SystemConfigService, MockSmsCodeService],
})
export class SystemConfigModule {}
@@ -0,0 +1,243 @@
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types';
/** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'feature', label: '功能开关' },
{ key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' },
{ key: 'wechat_mini', label: '微信小程序配置' },
{ key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
{ key: 'winery_bank', label: '酒厂银行账户' },
{ key: 'finance', label: '财务结算' },
];
const G = {
feature: 'feature',
sms: 'sms',
wechat: 'wechat',
wechat_mini: 'wechat_mini',
oss: 'oss',
app: 'app',
deploy: 'deploy',
winery_bank: 'winery_bank',
finance: 'finance',
} as const;
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{
key: 'MOCK_SMS',
label: 'Mock 短信',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启后不发真实短信,验证码为随机 6 位数字,并记录在下方列表',
},
{
key: 'MOCK_PAY',
label: 'Mock 支付',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '关闭且已配置微信商户参数时走真实 JSAPI 支付',
},
{
key: 'MOCK_WECHAT',
label: 'Mock 微信授权',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启走 Mock OAuth/登录;关闭且已配置 WX_APP_ID/SECRET 时走真实微信',
},
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false },
{ key: 'AUTO_APPROVE_STORE', label: '门店自动审核通过', group: G.feature, type: 'boolean', requiresRestart: false },
{
key: 'WECOM_AIBOT_ENABLED',
label: '启用企微机器人长连接',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
},
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM', label: '核销确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_PROXY_ORDER', label: '代下单确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_ACCESS_KEY_ID', label: '短信 AccessKey ID', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'ALIYUN_SMS_ACCESS_KEY_SECRET', label: '短信 AccessKey Secret', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_APP_ID', label: '服务号 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_APP_SECRET', label: '服务号 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MINI_APP_ID', label: '小程序 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MINI_APP_SECRET', label: '小程序 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MCH_ID', label: '微信商户号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_SERIAL_NO', label: '商户证书序列号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_PRIVATE_KEY', label: '商户私钥 PEM', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{ key: 'WX_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{
key: 'WX_MINI_MSG_TOKEN',
label: '小程序消息推送 Token',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description:
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
},
{
key: 'WX_MINI_MSG_AES_KEY',
label: '小程序消息推送 EncodingAESKey',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description: '消息推送安全模式 EncodingAESKey43 位);明文模式可留空',
},
{
key: 'MINI_HOME_BANNERS',
label: '首页轮播图',
group: G.wechat_mini,
type: 'imageList',
requiresRestart: false,
description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张;上传后需点击右上角「保存」',
},
{
key: 'MINI_HOME_FOOTER_URL',
label: '首页底部图',
group: G.wechat_mini,
type: 'image',
requiresRestart: false,
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
},
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
{ key: 'OSS_REGION', label: 'OSS Region', group: G.oss, type: 'string', requiresRestart: true, placeholder: 'oss-cn-hangzhou' },
{ key: 'OSS_ENDPOINT', label: 'OSS Endpoint', group: G.oss, type: 'string', requiresRestart: true, description: '可选,内网 endpoint' },
{ key: 'OSS_AUTHORIZATION_V4', label: 'OSS V4 签名', group: G.oss, type: 'boolean', requiresRestart: true },
{ key: 'OSS_CDN_BASE', label: 'OSS 公网域名', group: G.oss, type: 'string', requiresRestart: false },
{ key: 'OSS_UPLOAD_PREFIX', label: '上传前缀', group: G.oss, type: 'string', requiresRestart: false, placeholder: 'uploads' },
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{
key: 'TENCENT_LBS_KEY',
label: '腾讯位置服务 Key',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description: '须开启 WebServiceAPI;服务端地理编码/地点搜索与前端选点组件共用',
},
{
key: 'TENCENT_LBS_SECRET_KEY',
label: '腾讯位置服务 SecretKeySK',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description:
'控制台开启 WebServiceAPI「签名校验」后生成;仅服务端计算 sig,勿泄露。配置后接口请求自动附带签名',
},
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
{
key: 'SENTRY_DSN',
label: 'Sentry DSN',
group: G.deploy,
type: 'password',
secret: true,
requiresRestart: true,
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
},
{
key: 'WINERY_BANK_ACCOUNT_NAME',
label: '户名',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
description: '酒厂收款账户户名,打款时对照',
},
{
key: 'WINERY_BANK_NAME',
label: '开户银行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'WINERY_BANK_BRANCH',
label: '开户支行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
placeholder: '可选',
},
{
key: 'WINERY_BANK_ACCOUNT_NO',
label: '银行账号',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'STORE_WITHDRAW_DAILY_LIMIT',
label: '门店未出账提现单日上限(元)',
group: G.finance,
type: 'number',
requiresRestart: false,
description: 'FIN-002:单店单日提现上限,默认 5000',
placeholder: '5000',
},
];
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
export const SYSTEM_CONFIG_RETIRED_KEYS = [
'NODE_ENV',
'DATABASE_URL',
'REDIS_URL',
'PORT',
'TRUST_PROXY',
'JWT_SECRET',
'JWT_EXPIRES_IN',
'MOCK_SMS_CODE',
'WX_AUTHORIZE',
'WECHAT_AUTH_ENABLED',
'WECHAT_PAY_ENABLED',
'OSS_ENABLED',
'WECOM_AIBOT_BOT_ID',
'WECOM_AIBOT_SECRET',
'WECOM_AIBOT_WELCOME',
'WECOM_BOT_CS_ENABLED',
'WECOM_BOT_CS_BOT_ID',
'WECOM_BOT_CS_SECRET',
'WECOM_BOT_CS_WELCOME',
'WECOM_BOT_CS_PERMISSIONS',
'WECOM_BOT_TECH_ENABLED',
'WECOM_BOT_TECH_BOT_ID',
'WECOM_BOT_TECH_SECRET',
'WECOM_BOT_TECH_WELCOME',
'WECOM_BOT_TECH_PERMISSIONS',
'WECOM_BOT_TEAM_ENABLED',
'WECOM_BOT_TEAM_BOT_ID',
'WECOM_BOT_TEAM_SECRET',
'WECOM_BOT_TEAM_WELCOME',
'WECOM_BOT_TEAM_PERMISSIONS',
] as const;
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key);
}
@@ -0,0 +1,263 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { writeFileSync } from 'fs';
import type {
SystemConfigFormResponse,
SystemConfigSyncResult,
SystemConfigUpdateRequest,
} from '@dukang/shared-types';
import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
import {
SYSTEM_CONFIG_FIELDS,
SYSTEM_CONFIG_GROUPS,
SYSTEM_CONFIG_KEY_SET,
SYSTEM_CONFIG_RETIRED_KEYS,
getSystemConfigField,
} from './system-config.registry';
import { applyEnvOverlay, resolveEnvFilePath } from './system-config.env';
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
const SECRET_PLACEHOLDER = '********';
function isMissingSystemConfigTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
);
}
@Injectable()
export class SystemConfigService implements OnModuleInit {
private lastUpdatedAt: Date | null = null;
private tableReady = true;
constructor(
private readonly prisma: PrismaService,
private readonly mockSmsCodes: MockSmsCodeService,
) {}
async onModuleInit() {
try {
await this.purgeRetiredKeys();
await this.seedMissingFromProcessEnv();
const rows = await this.prisma.systemConfig.findMany();
applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value])));
this.lastUpdatedAt = rows.reduce<Date | null>(
(max, r) => (!max || r.updatedAt > max ? r.updatedAt : max),
null,
);
} catch (error) {
if (!isMissingSystemConfigTable(error)) throw error;
this.tableReady = false;
console.warn('[config] system_config 表不存在,请执行 npx prisma db push');
}
}
getMergedEnv(): Record<string, string | undefined> {
return { ...process.env };
}
getAppConfig() {
return loadAppConfig(this.getMergedEnv());
}
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
if (!this.tableReady) {
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
}
const groups =
allowedGroups == null
? SYSTEM_CONFIG_GROUPS
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
const allowedGroupSet = new Set(groups.map((g) => g.key));
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
const rows = await this.prisma.systemConfig.findMany();
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
const values: Record<string, string> = {};
const configuredSecrets: string[] = [];
for (const field of fields) {
const fromDb = dbMap.get(field.key);
const fromEnv = process.env[field.key];
const raw = fromDb ?? fromEnv ?? '';
if (field.secret) {
if (raw) configuredSecrets.push(field.key);
values[field.key] = '';
} else {
values[field.key] = raw;
}
}
return {
groups,
fields,
values,
configuredSecrets,
envFilePath: resolveEnvFilePath(),
updatedAt: this.lastUpdatedAt?.toISOString() ?? null,
mockSmsCodes: await this.mockSmsCodes.listRecent(),
};
}
async update(
dto: SystemConfigUpdateRequest,
allowedGroups?: string[] | null,
): Promise<{
updatedKeys: string[];
requiresRestartKeys: string[];
}> {
const updatedKeys: string[] = [];
const requiresRestartKeys: string[] = [];
const overlay: Record<string, string> = {};
const allowedGroupSet =
allowedGroups == null ? null : new Set(allowedGroups);
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
const meta = getSystemConfigField(key);
if (!meta) continue;
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
let value = String(rawValue ?? '').trim();
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
continue;
}
value = this.normalizeByMeta(meta, value);
await this.prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, value },
update: { value },
});
overlay[key] = value;
updatedKeys.push(key);
if (meta.requiresRestart) requiresRestartKeys.push(key);
}
if (updatedKeys.length) {
applyEnvOverlay(overlay);
const latest = await this.prisma.systemConfig.findFirst({
orderBy: { updatedAt: 'desc' },
select: { updatedAt: true },
});
this.lastUpdatedAt = latest?.updatedAt ?? new Date();
}
return { updatedKeys, requiresRestartKeys: [...new Set(requiresRestartKeys)] };
}
async syncToEnvFile(): Promise<SystemConfigSyncResult> {
const rows = await this.prisma.systemConfig.findMany();
const path = resolveEnvFilePath();
const lines: string[] = [
'# 由 HQ 系统设置同步生成,请勿手工删改键名',
`# synced_at=${new Date().toISOString()}`,
'',
];
let currentGroup = '';
for (const field of SYSTEM_CONFIG_FIELDS) {
if (field.group !== currentGroup) {
currentGroup = field.group;
const groupLabel = SYSTEM_CONFIG_GROUPS.find((g) => g.key === currentGroup)?.label ?? currentGroup;
lines.push(`# --- ${groupLabel} ---`);
}
const row = rows.find((r) => r.configKey === field.key);
const value = row?.value ?? process.env[field.key] ?? '';
lines.push(formatEnvLine(field.key, value));
}
lines.push('');
writeFileSync(path, lines.join('\n'), 'utf8');
const requiresRestartKeys = SYSTEM_CONFIG_FIELDS.filter((f) => f.requiresRestart).map((f) => f.key);
return {
envFilePath: path,
writtenKeys: SYSTEM_CONFIG_FIELDS.length,
requiresRestartKeys,
message: `已写入 ${path},共 ${SYSTEM_CONFIG_FIELDS.length} 项。修改「需重启」类配置后请重启 API 进程。`,
};
}
async importFromProcessEnv(): Promise<{ imported: number }> {
let imported = 0;
const overlay: Record<string, string> = {};
for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue;
const normalized = this.normalizeByMeta(field, envVal);
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing?.value?.trim()) continue;
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value: normalized },
update: { value: normalized },
});
overlay[field.key] = normalized;
imported += 1;
}
if (imported) applyEnvOverlay(overlay);
await this.onModuleInit();
return { imported };
}
private async purgeRetiredKeys() {
await this.prisma.systemConfig.deleteMany({
where: {
configKey: { in: [...SYSTEM_CONFIG_RETIRED_KEYS] },
},
});
}
private async seedMissingFromProcessEnv() {
for (const field of SYSTEM_CONFIG_FIELDS) {
const envVal = process.env[field.key];
if (envVal === undefined || envVal === '') continue;
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
if (existing?.value?.trim()) continue;
const value = this.normalizeByMeta(field, envVal);
await this.prisma.systemConfig.upsert({
where: { configKey: field.key },
create: { configKey: field.key, value },
update: { value },
});
}
}
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
if (meta.type === 'boolean') {
return raw === 'true' || raw === '1' ? 'true' : 'false';
}
if (meta.type === 'imageList') {
return serializeMiniHomeBanners(parseMiniHomeBanners(raw));
}
if (meta.type === 'image') {
return raw.trim();
}
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
let value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).trim();
}
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
}
return raw;
}
}
function formatEnvLine(key: string, value: string): string {
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
if (!needsQuote) return `${key}=${value}`;
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/\r\n/g, '\\n')
.replace(/\n/g, '\\n')
.replace(/"/g, '\\"');
return `${key}="${escaped}"`;
}
@@ -0,0 +1,85 @@
import type { PrismaService } from '../../common/prisma/prisma.module';
import { XIAOFEIXIA_CMD } from './xiaofeixia/xiaofeixia.constants';
const CMD_SCENE: Record<string, string> = {
[XIAOFEIXIA_CMD.CREATE_ORDER]: 'CREATE_SHIPMENT',
[XIAOFEIXIA_CMD.TRACK_ROUTE]: 'GET_TRACK',
[XIAOFEIXIA_CMD.CANCEL_ORDER]: 'CANCEL_SHIPMENT',
[XIAOFEIXIA_CMD.GET_ORDER]: 'GET_SHIPMENT',
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
};
export type CourierLogRef = {
refType?: string;
refId?: bigint;
};
export type LogCourierCallInput = {
scene: string;
requestUrl: string;
requestBody?: Record<string, unknown>;
responseBody?: unknown;
externalNo?: string;
status: 'SUCCESS' | 'FAILED' | 'PENDING';
errorMessage?: string;
ref?: CourierLogRef;
};
function maskMchId(mchId?: string) {
if (!mchId) return mchId;
if (mchId.length <= 4) return '****';
return `${mchId.slice(0, 4)}****`;
}
/** 请求体入库前脱敏(去掉 sign,商户号打码) */
export function sanitizeXfxRequestBody(body: Record<string, unknown>) {
const { sign: _sign, mchId, ...rest } = body;
return {
...rest,
...(mchId != null ? { mchId: maskMchId(String(mchId)) } : {}),
};
}
export function sceneForXfxCmd(cmd: string) {
return CMD_SCENE[cmd] ?? `XFX_CMD_${cmd}`;
}
export async function logCourierCall(prisma: PrismaService, input: LogCourierCallInput) {
const responseBody =
input.responseBody === undefined
? undefined
: typeof input.responseBody === 'object' && input.responseBody !== null
? (input.responseBody as Record<string, unknown>)
: { value: input.responseBody };
const row = await prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: input.scene,
refType: input.ref?.refType,
refId: input.ref?.refId,
requestUrl: input.requestUrl.slice(0, 512),
requestBody: input.requestBody as never,
responseBody: responseBody as never,
externalNo: input.externalNo?.slice(0, 128),
status: input.status,
errorMessage: input.errorMessage?.slice(0, 512),
},
});
return row.id;
}
export async function resolveOrderRefByOutNumber(
prisma: PrismaService,
outNumber?: string,
): Promise<CourierLogRef | undefined> {
if (!outNumber?.trim()) return undefined;
const order = await prisma.order.findUnique({
where: { orderNo: outNumber.trim() },
select: { id: true },
});
if (!order) return undefined;
return { refType: 'ORDER', refId: order.id };
}
@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CourierProviderCode } from './courier.constants';
export type XiaofeixiaSignType = 'MD5' | 'HMAC-SHA256';
export interface XiaofeixiaConfig {
apiUrl: string;
appId?: string;
mchId: string;
apiKey: string;
signType: XiaofeixiaSignType;
}
export interface CourierIntegrationConfig {
provider: CourierProviderCode;
xiaofeixia: XiaofeixiaConfig;
}
@Injectable()
export class CourierConfigService {
constructor(private readonly config: ConfigService) {}
load(): CourierIntegrationConfig {
const providerRaw = (this.config.get<string>('COURIER_PROVIDER') ?? 'xiaofeixia').toLowerCase();
const provider = this.resolveProvider(providerRaw);
return {
provider,
xiaofeixia: {
apiUrl:
this.config.get<string>('XIAOFEIXIA_API_URL')?.trim() ||
'https://beta.51xiaoju.cn/app/api/interface.do',
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
apiKey: this.config.get<string>('XIAOFEIXIA_API_KEY') ?? '',
signType: this.resolveSignType(this.config.get<string>('XIAOFEIXIA_SIGN_TYPE')),
},
};
}
private resolveProvider(raw: string): CourierProviderCode {
switch (raw) {
case 'xiaofeixia':
return CourierProviderCode.XIAOFEIXIA;
case 'sf':
return CourierProviderCode.SF;
case 'jd':
return CourierProviderCode.JD;
default:
throw new Error(`Unsupported COURIER_PROVIDER: ${raw}`);
}
}
private resolveSignType(raw?: string): XiaofeixiaSignType {
if (raw?.toUpperCase() === 'HMAC-SHA256') {
return 'HMAC-SHA256';
}
return 'MD5';
}
}
@@ -0,0 +1,7 @@
export const COURIER_PROVIDER = 'COURIER_PROVIDER';
export enum CourierProviderCode {
XIAOFEIXIA = 'XIAOFEIXIA',
SF = 'SF',
JD = 'JD',
}
@@ -0,0 +1,11 @@
export class CourierApiError extends Error {
constructor(
message: string,
readonly code: string,
readonly providerCode?: string,
readonly raw?: unknown,
) {
super(message);
this.name = 'CourierApiError';
}
}
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { COURIER_PROVIDER, CourierProviderCode } from './courier.constants';
import { CourierConfigService } from './courier.config';
import { CourierService } from './courier.service';
import { XiaofeixiaClient } from './xiaofeixia/xiaofeixia.client';
import { XiaofeixiaProvider } from './xiaofeixia/xiaofeixia.provider';
import type { ICourierProvider } from './courier.types';
@Module({
providers: [
CourierConfigService,
XiaofeixiaClient,
XiaofeixiaProvider,
{
provide: COURIER_PROVIDER,
useFactory: (
configService: CourierConfigService,
xiaofeixia: XiaofeixiaProvider,
): ICourierProvider => {
const { provider } = configService.load();
switch (provider) {
case CourierProviderCode.XIAOFEIXIA:
return xiaofeixia;
default:
throw new Error(`Courier provider not implemented: ${provider}`);
}
},
inject: [CourierConfigService, XiaofeixiaProvider],
},
CourierService,
],
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
})
export class CourierModule {}
@@ -0,0 +1,59 @@
import { Inject, Injectable } from '@nestjs/common';
import { COURIER_PROVIDER } from './courier.constants';
import type {
BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput,
CreateShipmentResult,
DeliveryCoverageResult,
FreightEstimateResult,
ICourierProvider,
ShipmentDetail,
ShipmentQuery,
TrackCallbackResponse,
TrackNode,
} from './courier.types';
/**
* 快递统一门面:业务层只依赖本 Service,不感知具体快递商实现。
*/
@Injectable()
export class CourierService {
constructor(@Inject(COURIER_PROVIDER) private readonly provider: ICourierProvider) {}
get activeProvider() {
return this.provider.code;
}
createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
return this.provider.createShipment(input, options);
}
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
return this.provider.cancelShipment(query, options);
}
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
return this.provider.getShipment(query, options);
}
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
return this.provider.batchGetShipments(query, options);
}
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
return this.provider.getTrack(query, options);
}
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress, options);
}
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
return this.provider.estimateFreight(weight, options);
}
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
return this.provider.buildTrackCallbackResponse(success);
}
}
@@ -0,0 +1,123 @@
import { CourierProviderCode } from './courier.constants';
import type { XiaofeixiaConfig } from './courier.config';
/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */
export interface CourierCallOptions {
xiaofeixia?: XiaofeixiaConfig;
}
export interface CourierCoordinate {
lng: number;
lat: number;
}
export interface CourierContact {
name: string;
mobile: string;
address: string;
addressDetail: string;
coordinate?: CourierCoordinate;
}
/** 付费对象:寄付 / 到付 */
export enum CourierPayMode {
SENDER = '1',
RECEIVER = '2',
}
export interface CreateShipmentInput {
outNumber: string;
customerId?: string;
from: CourierContact;
to: CourierContact;
goodsName?: string;
goodsNum?: number;
weight?: number;
insuredSumPrice?: number;
collectionPrice?: number;
payMode: CourierPayMode;
remark?: string;
}
export interface CreateShipmentResult {
providerShipmentId: number;
trackingNumber: string;
}
export interface ShipmentQuery {
trackingNumber?: string;
outNumber?: string;
}
export interface BatchShipmentQuery {
trackingNumbers?: string[];
outNumbers?: string[];
}
export interface TrackNode {
trackInfo: string;
createTime: string;
statusName: string;
}
export interface ShipmentDetail {
trackingNumber: string;
toCarrierName?: string;
toSiteName?: string;
toName: string;
toMobile: string;
toAddress: string;
toAddressDetail?: string;
fromCarrierName?: string;
fromSiteName?: string;
fromName: string;
fromMobile: string;
fromAddress: string;
fromAddressDetail?: string;
weight?: number;
payModeName?: string;
freightPrice?: string;
insuredPrice?: number;
collectionPrice?: number;
sumPrice?: number;
goodsName?: string;
remark?: string;
}
export interface DeliveryCoverageResult {
arriveTime: string;
siteName: string;
siteId: string;
}
export interface FreightEstimateResult {
freightPrice: number;
}
/** 路由变化回调(各快递商 POST 到业务方) */
export interface TrackCallbackPayload {
outNumber: string;
trackingNumber: string;
status: string;
statusName: string;
trackInfo: string;
createTime: string;
}
export interface TrackCallbackResponse {
code: string;
message: string;
}
export interface ICourierProvider {
readonly code: CourierProviderCode;
createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult>;
cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void>;
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
}
@@ -0,0 +1,5 @@
export * from './courier.constants';
export * from './courier.types';
export * from './courier.error';
export * from './courier.service';
export * from './courier.module';
@@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { CourierApiError } from '../courier.error';
import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config';
import { PrismaService } from '../../../common/prisma/prisma.module';
import {
logCourierCall,
resolveOrderRefByOutNumber,
sanitizeXfxRequestBody,
sceneForXfxCmd,
} from '../courier-log.util';
import { buildXiaofeixiaSign } from './xiaofeixia.sign';
import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants';
import type { XiaofeixiaApiResponse } from './xiaofeixia.types';
type RequestParams = Record<string, string | number | undefined>;
@Injectable()
export class XiaofeixiaClient {
constructor(
private readonly courierConfig: CourierConfigService,
private readonly prisma: PrismaService,
) {}
async request<T>(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise<T> {
const cfg = override ?? this.courierConfig.load().xiaofeixia;
const scene = sceneForXfxCmd(cmd);
const externalNo = this.pickExternalNo(bizParams);
if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl || '(未配置)',
requestBody: sanitizeXfxRequestBody({ cmd, ...bizParams, mchId: cfg.mchId }),
status: 'FAILED',
errorMessage: '小飞侠商户配置不完整',
externalNo,
});
throw new CourierApiError(
'小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key',
'CONFIG_ERROR',
'XIAOFEIXIA',
);
}
const baseParams: RequestParams = {
mchId: cfg.mchId,
cmd,
signType: cfg.signType,
...bizParams,
};
if (cfg.appId) {
baseParams.appId = cfg.appId;
}
const sign = buildXiaofeixiaSign(baseParams, cfg.apiKey, cfg.signType);
const body = new URLSearchParams();
for (const [key, value] of Object.entries({ ...baseParams, sign })) {
if (value !== undefined && value !== null && value !== '') {
body.append(key, String(value));
}
}
const logRequestBody = sanitizeXfxRequestBody({ ...baseParams, sign: '[REDACTED]' });
const orderRef = await resolveOrderRefByOutNumber(
this.prisma,
typeof bizParams.outNumber === 'string' ? bizParams.outNumber : undefined,
);
let response: Response;
try {
response = await fetch(cfg.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
status: 'FAILED',
errorMessage: `网络异常: ${message}`,
externalNo,
ref: orderRef,
});
throw new CourierApiError('小飞侠接口网络异常', '200000', 'XIAOFEIXIA', error);
}
const rawText = await response.text();
if (!response.ok) {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
responseBody: { httpStatus: response.status, body: rawText.slice(0, 500) },
status: 'FAILED',
errorMessage: `HTTP ${response.status}`,
externalNo,
ref: orderRef,
});
throw new CourierApiError(
`小飞侠 HTTP 请求失败: ${response.status}`,
'200000',
'XIAOFEIXIA',
);
}
let payload: XiaofeixiaApiResponse<T>;
try {
payload = rawText
? (JSON.parse(rawText) as XiaofeixiaApiResponse<T>)
: (null as unknown as XiaofeixiaApiResponse<T>);
} catch {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
responseBody: { raw: rawText.slice(0, 500) },
status: 'FAILED',
errorMessage: '响应非 JSON',
externalNo,
ref: orderRef,
});
throw new CourierApiError(
`小飞侠响应非 JSONHTTP ${response.status}: ${rawText.slice(0, 200) || '(空)'}`,
'200000',
'XIAOFEIXIA',
rawText,
);
}
if (!payload) {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
status: 'FAILED',
errorMessage: '小飞侠返回空响应',
externalNo,
ref: orderRef,
});
throw new CourierApiError('小飞侠返回空响应', '200000', 'XIAOFEIXIA');
}
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
responseBody: payload as unknown as Record<string, unknown>,
status: 'FAILED',
errorMessage: payload.message || '业务失败',
externalNo: externalNo || payload.data?.toString(),
ref: orderRef,
});
throw new CourierApiError(
payload.message || '小飞侠接口业务失败',
payload.code,
'XIAOFEIXIA',
payload,
);
}
await logCourierCall(this.prisma, {
scene,
requestUrl: cfg.apiUrl,
requestBody: logRequestBody,
responseBody: payload as unknown as Record<string, unknown>,
status: 'SUCCESS',
externalNo: externalNo || this.pickExternalNoFromData(payload.data),
ref: orderRef,
});
return payload.data as T;
}
private pickExternalNo(params: RequestParams) {
const outNumber = params.outNumber != null ? String(params.outNumber) : undefined;
const number = params.number != null ? String(params.number) : undefined;
return outNumber || number;
}
private pickExternalNoFromData(data: unknown) {
if (!data || typeof data !== 'object') return undefined;
const row = data as { number?: string; outNumber?: string; id?: string };
return row.number || row.outNumber || row.id;
}
}
@@ -0,0 +1,12 @@
/** 小飞侠接口 cmd 编号 */
export const XIAOFEIXIA_CMD = {
CREATE_ORDER: '100101',
TRACK_ROUTE: '100102',
CANCEL_ORDER: '100103',
GET_ORDER: '100104',
ESTIMATE_FREIGHT: '100105',
BATCH_GET_ORDER: '100106',
DELIVERY_COVERAGE: '100301',
} as const;
export const XIAOFEIXIA_SUCCESS_CODE = '100000';
@@ -0,0 +1,191 @@
import { Injectable } from '@nestjs/common';
import { CourierProviderCode } from '../courier.constants';
import { CourierApiError } from '../courier.error';
import type {
BatchShipmentQuery,
CourierCallOptions,
CreateShipmentInput,
CreateShipmentResult,
DeliveryCoverageResult,
FreightEstimateResult,
ICourierProvider,
ShipmentDetail,
ShipmentQuery,
TrackCallbackResponse,
TrackNode,
} from '../courier.types';
import { XiaofeixiaClient } from './xiaofeixia.client';
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
import type {
XiaofeixiaCreateOrderData,
XiaofeixiaDeliveryCoverageData,
XiaofeixiaFreightEstimateData,
XiaofeixiaOrderDetail,
XiaofeixiaTrackNode,
} from './xiaofeixia.types';
@Injectable()
export class XiaofeixiaProvider implements ICourierProvider {
readonly code = CourierProviderCode.XIAOFEIXIA;
constructor(private readonly client: XiaofeixiaClient) {}
async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
const data = await this.client.request<XiaofeixiaCreateOrderData>(
XIAOFEIXIA_CMD.CREATE_ORDER,
{
customerId: input.customerId,
outNumber: input.outNumber,
fromAddress: input.from.address,
fromAddressDetail: input.from.addressDetail,
fromCoordinate: this.formatCoordinate(input.from.coordinate),
fromMobile: input.from.mobile,
fromName: input.from.name,
toAddress: input.to.address,
toAddressDetail: input.to.addressDetail,
toCoordinate: this.formatCoordinate(input.to.coordinate),
toMobile: input.to.mobile,
toName: input.to.name,
goodsName: input.goodsName,
goodsNum: input.goodsNum,
weight: input.weight,
insuredSumPrice: input.insuredSumPrice,
collectionPrice: input.collectionPrice,
payMode: input.payMode,
remark: input.remark,
},
options?.xiaofeixia,
);
return {
providerShipmentId: data.id,
trackingNumber: data.number,
};
}
async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
this.assertShipmentQuery(query);
await this.client.request(
XIAOFEIXIA_CMD.CANCEL_ORDER,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
}
async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaOrderDetail>(
XIAOFEIXIA_CMD.GET_ORDER,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return this.mapOrderDetail(data);
}
async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
const number = query.trackingNumbers?.join(',');
const outNumber = query.outNumbers?.join(',');
if (!number && !outNumber) {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
}
const data = await this.client.request<XiaofeixiaOrderDetail[]>(
XIAOFEIXIA_CMD.BATCH_GET_ORDER,
{
number,
outNumber,
},
options?.xiaofeixia,
);
return (data ?? []).map((item) => this.mapOrderDetail(item));
}
async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
this.assertShipmentQuery(query);
const data = await this.client.request<XiaofeixiaTrackNode[]>(
XIAOFEIXIA_CMD.TRACK_ROUTE,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return data ?? [];
}
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
{ toAddress },
options?.xiaofeixia,
);
return {
arriveTime: data.arriveTime,
siteName: data.name,
siteId: data.id,
};
}
async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
{ weight },
options?.xiaofeixia,
);
return { freightPrice: data.freightPrice };
}
buildTrackCallbackResponse(success = true): TrackCallbackResponse {
return {
code: success ? '100000' : '300000',
message: success ? 'success' : 'fail',
};
}
private assertShipmentQuery(query: ShipmentQuery): void {
if (!query.trackingNumber && !query.outNumber) {
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
}
}
private formatCoordinate(coordinate?: { lng: number; lat: number }): string | undefined {
if (!coordinate) return undefined;
return JSON.stringify({ lng: coordinate.lng, lat: coordinate.lat });
}
private mapOrderDetail(data: XiaofeixiaOrderDetail): ShipmentDetail {
return {
trackingNumber: data.number,
toCarrierName: data.toCarrierName,
toSiteName: data.toSiteName,
toName: data.toName,
toMobile: data.toMobile,
toAddress: data.toAddress,
toAddressDetail: data.toAddressDetail,
fromCarrierName: data.fromCarrierName,
fromSiteName: data.fromSiteName,
fromName: data.fromName,
fromMobile: data.fromMobile,
fromAddress: data.fromAddress,
fromAddressDetail: data.fromAddressDetail,
weight: data.weight,
payModeName: data.payModeName,
freightPrice: data.freightPrice,
insuredPrice: data.insuredPrice,
collectionPrice: data.collectionPrice,
sumPrice: data.sumPrice,
goodsName: data.goodsName,
remark: data.remark,
};
}
}
@@ -0,0 +1,28 @@
import { createHash, createHmac } from 'crypto';
import type { XiaofeixiaSignType } from '../courier.config';
type SignParams = Record<string, string | number | undefined | null>;
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === '';
}
/** 按 ASCII 字典序拼接并生成签名 */
export function buildXiaofeixiaSign(
params: SignParams,
apiKey: string,
signType: XiaofeixiaSignType = 'MD5',
): string {
const sortedKeys = Object.keys(params)
.filter((key) => key !== 'sign' && !isEmpty(params[key]))
.sort();
const stringA = sortedKeys.map((key) => `${key}=${params[key]}`).join('&');
const stringSignTemp = `${stringA}&key=${apiKey}`;
if (signType === 'HMAC-SHA256') {
return createHmac('sha256', apiKey).update(stringSignTemp).digest('hex').toUpperCase();
}
return createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();
}
@@ -0,0 +1,57 @@
export interface XiaofeixiaApiResponse<T> {
cmd: string;
code: string;
message: string;
bizCode?: string;
data?: T;
}
export interface XiaofeixiaCreateOrderData {
id: number;
number: string;
}
export interface XiaofeixiaTrackNode {
trackInfo: string;
createTime: string;
statusName: string;
}
export interface XiaofeixiaOrderDetail {
number: string;
toCarrierName?: string;
toSiteName?: string;
toName: string;
toMobile: string;
toAddress: string;
toAddressDetail?: string;
fromCarrierName?: string;
fromSiteName?: string;
fromName: string;
fromMobile: string;
fromAddress: string;
fromAddressDetail?: string;
weight?: number;
payModeName?: string;
freightPrice?: string;
insuredPrice?: number;
collectionPrice?: number;
sumPrice?: number;
goodsName?: string;
remark?: string;
}
export interface XiaofeixiaDeliveryCoverageData {
arriveTime: string;
name: string;
id: string;
}
export interface XiaofeixiaFreightEstimateData {
freightPrice: number;
}
export interface XiaofeixiaCancelOrderData {
id: number;
number: string;
}
@@ -0,0 +1,4 @@
export interface IDeliveryProvider {
scheduleAutoAdvance(orderId: bigint): Promise<void>;
advanceTo(orderId: bigint, targetStatus: string): Promise<void>;
}

Some files were not shown because too many files have changed in this diff Show More