feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# 杜康 API 环境变量模板
|
||||
# 本地开发:复制为 .env 后按需修改(pnpm dev:api 仅读取 .env / .env.local)
|
||||
# 可选 .env.local — 本机覆盖(gitignore)
|
||||
# 服务器:仅使用 .env.production(bash 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=
|
||||
|
||||
# 阿里云 OSS(ali-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
|
||||
@@ -0,0 +1,68 @@
|
||||
# 生产环境(真实第三方,关闭 Mock)
|
||||
# 用途:服务器 /opt/dukang;bash 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
|
||||
@@ -0,0 +1,74 @@
|
||||
# Staging / 测试环境(同机双栈,正式微信 AppId + 全 Mock)
|
||||
# 用途:服务器 /opt/dukang-staging;sync: 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
|
||||
@@ -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`
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 — ops(HQ 后台)
|
||||
|
||||
| 路径 | 关键改动 |
|
||||
|------|----------|
|
||||
| `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 + store(Redis 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 已激活**) |
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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_partner(CITY_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
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 @@
|
||||
/**
|
||||
* 上传小程序「资质公示」静态图到 OSS:static/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);
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 小程序消息推送(发货信息管理事件)。
|
||||
* 后台配置 URL:https://{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: '消息推送安全模式 EncodingAESKey(43 位);明文模式可留空',
|
||||
},
|
||||
|
||||
{
|
||||
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: '腾讯位置服务 SecretKey(SK)',
|
||||
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(
|
||||
`小飞侠响应非 JSON(HTTP ${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
Reference in New Issue
Block a user