feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-282
View File
@@ -1,282 +0,0 @@
# V3.1 Schema 迁移清单
> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`
> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`
## 激活步骤(P0
```bash
cd server/dukang-api
# 1. 备份并切换 schema
cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份
cp prisma/schema.v31.prisma prisma/schema.prisma
# 2. 开发库建议删库重建
mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..."
# 或:mysql < prisma/init_v3.sql
# 3. 生成 Client
pnpm db:generate
npx prisma db push # 或 prisma migrate dev --name v31_init
# 4. 新 seed(待编写 seed-v31.ts
pnpm prisma:seed
```
---
## 表对照(27 张 v3.1
| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 |
|-------------|--------------|------------|------|
| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 |
| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL |
| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** |
| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** |
| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` |
| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 |
| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` |
| `common_city` | `CommonCity` | `cities` / `City` | 重命名 |
| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 |
| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 |
| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 |
| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 |
| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 |
| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 |
| `user_address` | `UserAddress` | `user_addresses` | 重命名 |
| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 |
| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 |
| `store_store` | `Store` | `stores` | `cover_url``cover_resource_id` |
| `store_account` | `StoreAccount` | `store_accounts` | 重命名 |
| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 |
| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` |
| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 |
| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 |
| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 |
| `store_payout` | `StorePayout` | `store_payouts` | 重命名 |
| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** |
| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** |
### 删除的表(v3.1 不再存在)
| 旧表 | 替代方案 |
|------|----------|
| `store_media` | `common_resource``owner_type=STORE` |
| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT |
| `order_items` | `user_order` 内嵌快照字段 |
| `order_status_logs` | `common_event(ORDER_STATUS)` |
| `payments` | `log_third_party` + `user_order.pay_*` |
| `refunds` | `common_ticket(REFUND)` |
| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT |
| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` |
| `redeem_tokens` | **仅 Redis** |
| `order_commissions` | `partner_bill` 汇总(无明细表) |
| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) |
| `store_audits` | `common_event(STORE_AUDIT)` |
| `after_sale_tickets` | `common_ticket` |
| `alerts` | `common_ticket(ALERT)` |
| `operation_logs` | `common_event(HQ_OPERATION)` |
| `event_logs` | `common_event` + `log_user_analytics` |
### preV1 扩展字段(已并入 v3.1
| 字段 | 表 | 说明 |
|------|-----|------|
| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 |
| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 |
~~以下字段在切换后丢失~~**已保留**
---
## Prisma Client 调用变更速查
| 旧调用 | v3.1 调用 |
|--------|-----------|
| `prisma.product` | `prisma.commonProductItem` |
| `prisma.city` | `prisma.commonCity` |
| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` |
| `prisma.storeCategory` | `prisma.commonStoreCategory` |
| `prisma.promoCode` | `prisma.commonPromoCode` |
| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery` |
| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon` |
| `prisma.benefitLedger` | `prisma.commonEvent``eventType=BENEFIT_LEDGER` |
| `prisma.redeemToken` | **删除**,改 Redis |
| `prisma.storeMedia` | `prisma.commonResource` |
| `prisma.storeAudit` | `prisma.commonEvent``eventType=STORE_AUDIT` |
| `prisma.payment` | `prisma.logThirdParty` |
| `prisma.orderStatusLog` | `prisma.commonEvent``eventType=ORDER_STATUS` |
| `prisma.orderItem` | **删除**,读写 `order` 快照字段 |
> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。
---
## 后端模块影响面
### P0 — 基础设施
| 路径 | 影响 | 工作量 |
|------|------|--------|
| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ |
| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ |
| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ |
| `prisma/sync-benefit-to-price.ts` | `product``commonProductItem` | 低 |
| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 |
### P1 — common 模块(新建)
| 路径 | 说明 |
|------|------|
| `src/modules/common/common.module.ts` | **新建** |
| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD |
| `src/modules/common/resource.controller.ts` | `/common/resources/*` |
| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 |
| `src/modules/common/ticket.service.ts` | 工单 CRUD |
### P2 — IAM
| 路径 | 关键改动 |
|------|----------|
| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 |
| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 |
| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 |
| `common/guards/super-admin.guard.ts` | 无大变 |
### P3 — catalog
| 路径 | 关键改动 |
|------|----------|
| `modules/catalog/catalog.service.ts` | `city``commonCity``product``commonProductItem`;返回 `coverResource.url` |
### P4 — trade(改动最大)
| 路径 | 关键改动 |
|------|----------|
| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 |
| `integrations/pay/*` | 回调改查 `log_third_party` |
| `jobs/*`(配送 Mock | `orderDelivery` 字段对齐 |
### P5 — benefit
| 路径 | 关键改动 |
|------|----------|
| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create``commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` |
### P6 — redeem
| 路径 | 关键改动 |
|------|----------|
| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis`cityCommissionRule``commonCityCommissionRule` |
### P7 — store
| 路径 | 关键改动 |
|------|----------|
| `modules/store/store.service.ts` | `storeAudit``commonEvent`;封面改 `coverResourceId``city``commonCity` |
### P8 — settlement
| 路径 | 关键改动 |
|------|----------|
| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 |
### P9 — analytics
| 路径 | 关键改动 |
|------|----------|
| `modules/analytics/analytics.service.ts` | `eventLog``logUserAnalytics` |
### P10 — opsHQ 后台)
| 路径 | 关键改动 |
|------|----------|
| `modules/ops/admin-stores.service.ts` | `storeMedia``commonResource``coverUrl``coverResourceId``city``commonCity` |
| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` |
| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include |
| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 |
| `modules/ops/admin-cities.service.ts` | `city``commonCity` |
| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 |
| `modules/ops/admin-redeem.service.ts` | 表名映射 |
| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 |
| `admin-stores.controller.ts` | `/admin/store-media``/admin/resources` 或复用 common API |
---
## 前端影响面
| 应用 | 影响 |
|------|------|
| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 |
| `apps/h5-shop` | 门店详情封面 URL |
| `apps/h5-partner` | 录店上传走 `/common/resources` |
| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 |
| `packages/shared-types` | 枚举与 DTO 同步 |
---
## P1 进度(common 模块)
| 项 | 状态 |
|----|------|
| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ |
| `common/event/event.helpers.ts` 权益/订单事件 | ✅ |
| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ |
| `pnpm run build` | ✅ |
## P2–P6 进度(业务层 + 兼容层 + 后台)
| 项 | 状态 |
|----|------|
| P2 IAM + catalog 适配 v3.1 | ✅ |
| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ |
| P4 redeem + storeRedis token + 封面资源) | ✅ |
| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ |
| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ |
| `admin/products` CRUD + HQ 商品页 | ✅ |
| `sync-benefit-to-price.ts``commonProductItem` | ✅ |
| `schema.prisma``schema.v31.prisma` 同步 | ✅ |
| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ |
### 新增 HQ API
| 路径 | 说明 |
|------|------|
| `GET/POST /admin/products` | 商品列表/新建 |
| `GET/PUT /admin/products/:id` | 商品详情/更新 |
### 新增 API`/api/v1/common/*`
| 路径 | 说明 |
|------|------|
| `POST /common/resources/upload-token` | Mock OSS 直传凭证 |
| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD |
| `POST/GET /common/events` | 事件写入/查询 |
| `GET /common/events/timeline` | 时间线 |
| `POST/GET/PUT /common/tickets` | 工单 |
| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 |
## 建议实施顺序
```
P0 schema 切换 + seed-v31
→ P1 common 模块(resource + event
→ P2 IAM + catalog(可登录、可看商品)
→ P3 trade + benefit(可下单发券)
→ P4 redeem + store(可核销)
→ P5 ops 后台 + settlement
→ P6 前端对齐 + smoke
```
**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。
---
## 文件索引
| 文件 | 说明 |
|------|------|
| `prisma/init_v3.sql` | DDL 源 |
| `prisma/schema.v31.prisma` | **新生成**,待激活 |
| `prisma/schema.legacy-v21.prisma` | 旧版备份 |
| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活** |
-30
View File
@@ -1,30 +0,0 @@
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();
});
@@ -1,36 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
async function ensureColumn(table, column, ddl) {
const cols = await p.$queryRawUnsafe(`SHOW COLUMNS FROM \`${table}\` LIKE '${column}'`);
if (!cols.length) {
await p.$executeRawUnsafe(ddl);
console.log(`added ${table}.${column}`);
} else {
console.log(`${table}.${column} exists`);
}
}
(async () => {
await ensureColumn(
'wecom_bot',
'ai_enabled',
'ALTER TABLE `wecom_bot` ADD COLUMN `ai_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `permissions`',
);
await ensureColumn(
'wecom_bot',
'llm_config_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `llm_config_id` BIGINT UNSIGNED NULL AFTER `ai_enabled`',
);
await ensureColumn(
'wecom_bot',
'knowledge_base_id',
'ALTER TABLE `wecom_bot` ADD COLUMN `knowledge_base_id` BIGINT UNSIGNED NULL AFTER `llm_config_id`',
);
await p.$disconnect();
})().catch(async (e) => {
console.error(e);
await p.$disconnect();
process.exit(1);
});
-670
View File
@@ -1,670 +0,0 @@
============================================================
-- 杜康好客 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;
@@ -1,50 +0,0 @@
-- 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;
@@ -1,64 +0,0 @@
/**
* 将 legacy common_city.partner_id 迁移至 common_city_partnerCITY_WIDE)。
* 在 prisma db push 移除 partner_id 列之前运行;若列已不存在则跳过数据拷贝。
*
* 用法:cd server/dukang-api && pnpm prisma:migrate-city-partner
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
table,
column,
);
return Number(rows[0]?.cnt ?? 0) > 0;
}
async function main() {
const hasPartnerId = await columnExists('common_city', 'partner_id');
if (!hasPartnerId) {
console.log('[migrate-city-partner] common_city.partner_id 已移除,跳过迁移');
return;
}
const cities = await prisma.$queryRawUnsafe<
Array<{ id: bigint; partner_id: bigint | null }>
>(`SELECT id, partner_id FROM common_city WHERE partner_id IS NOT NULL`);
let migrated = 0;
for (const row of cities) {
const cityId = row.id;
const partnerId = row.partner_id!;
const existing = await prisma.cityPartner.findFirst({
where: { cityId, partnerId },
});
if (existing) continue;
const rule = await prisma.commonCityCommissionRule.findUnique({ where: { cityId } });
await prisma.cityPartner.create({
data: {
cityId,
partnerId,
scopeType: 'CITY_WIDE',
orderCommissionRate: rule ? Number((rule as { orderCommissionRate?: unknown }).orderCommissionRate ?? 0) : 0,
redeemCommissionRate: rule ? Number((rule as { redeemCommissionRate?: unknown }).redeemCommissionRate ?? 0.03) : 0.03,
status: 'ACTIVE',
},
});
migrated += 1;
}
console.log(`[migrate-city-partner] 已迁移 ${migrated} 条 CITY_WIDE 绑定`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -1,32 +0,0 @@
/**
* 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);
});
@@ -1,28 +0,0 @@
-- 推广码表扩展: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;
@@ -1,106 +0,0 @@
/**
* 一次性迁移: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());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-854
View File
@@ -1,854 +0,0 @@
// 杜康好客 · 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")
}
@@ -1,447 +0,0 @@
/**
* 财务四账单 Mock 数据(不重跑全量 seed)
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/seed-finance-mock.ts
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function billNo(prefix: string, suffix: string) {
return `${prefix}MOCK${suffix}`.slice(0, 32);
}
async function main() {
console.log('Seeding finance mock bills...');
const store = await prisma.store.findFirst({
where: { auditStatus: 'APPROVED' },
orderBy: { id: 'asc' },
});
if (!store) throw new Error('没有门店数据,请先 pnpm db:seed');
const partner = await prisma.partnerAccount.findFirst({
where: { isPrimary: 1, status: 'ACTIVE' },
orderBy: { id: 'asc' },
});
if (!partner) throw new Error('没有合伙人主账号,请先 pnpm db:seed');
let provider = await prisma.fulfillmentProvider.findFirst({
where: { code: { in: ['XFX', 'XIAOFEIXIA'] } },
});
if (!provider) {
provider = await prisma.fulfillmentProvider.findFirst({ orderBy: { id: 'asc' } });
}
if (!provider) throw new Error('没有仓配承运商,请先 pnpm db:seed 或在仓配管理注册');
// 确保小飞侠有对账配置,便于物流汇总页展示
if (!provider.pricingRulesJson || !provider.bankAccountName) {
provider = await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: {
settlementMethod: provider.settlementMethod ?? 'PREPAID',
prepaidBalance: Number(provider.prepaidBalance) > 0 ? provider.prepaidBalance : 5000,
bankAccountName: provider.bankAccountName || '郑州小飞侠物流有限公司',
bankName: provider.bankName || '招商银行',
bankBranch: provider.bankBranch || '郑州金水支行',
bankAccountNo: provider.bankAccountNo || '6225880000001234',
pricingRulesJson:
provider.pricingRulesJson ||
JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
},
});
}
const today = startOfDay(new Date());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dayBefore = new Date(today);
dayBefore.setDate(dayBefore.getDate() - 2);
const thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
const prevMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const prevMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0, 23, 59, 59, 999);
// 清理旧 mock(按账单号前缀)
await prisma.logisticsBillItem.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsPrepaidLedger.deleteMany({
where: { logisticsBill: { billNo: { startsWith: 'LBMOCK' } } },
});
await prisma.logisticsBill.deleteMany({ where: { billNo: { startsWith: 'LBMOCK' } } });
await prisma.wineryBillItem.deleteMany({
where: { wineryBill: { billNo: { startsWith: 'WBMOCK' } } },
});
await prisma.wineryBill.deleteMany({ where: { billNo: { startsWith: 'WBMOCK' } } });
await prisma.storePayout.updateMany({
where: { storeBill: { billNo: { startsWith: 'SBMOCK' } } },
data: { storeBillId: null },
});
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(昨日未打款 + 前日已打款)───
const storeBillUnpaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id,
billDate: yesterday,
redeemCount: 5,
redeemAmount: 1500,
settlementRate: 0.6,
payoutAmount: 900,
status: 'UNPAID',
},
});
const storeBillPaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id,
billDate: dayBefore,
redeemCount: 3,
redeemAmount: 800,
settlementRate: 0.6,
payoutAmount: 480,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 2. 合伙人月账单(上月待确认 + 本月已打款样例)───
const partnerBillAwait = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
partnerAccountId: partner.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
},
update: {
billNo: billNo('PB', `${prevMonthStart.getTime()}A`),
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
sentAt: yesterday,
paidAt: null,
rejectReason: null,
},
});
// 本月另起一条若冲突则跳过(unique partner+period)— 用上上月做已打款
const olderMonthStart = new Date(today.getFullYear(), today.getMonth() - 2, 1);
const olderMonthEnd = new Date(today.getFullYear(), today.getMonth() - 1, 0, 23, 59, 59, 999);
const partnerBillPaid = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
partnerAccountId: partner.id,
periodStart: olderMonthStart,
},
},
create: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
partnerAccountId: partner.id,
periodStart: olderMonthStart,
periodEnd: olderMonthEnd,
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
sentAt: olderMonthEnd,
confirmedAt: thisMonthStart,
paidAt: thisMonthStart,
},
update: {
billNo: billNo('PB', `${olderMonthStart.getTime()}P`),
orderCommission: 500,
redeemCommission: 300,
totalAmount: 800,
status: 'PAID',
paidAt: thisMonthStart,
},
});
// ─── 3. 酒厂日账单 ───
const wineryUnpaid = await prisma.wineryBill.upsert({
where: { billDate: yesterday },
create: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
billDate: yesterday,
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
},
update: {
billNo: billNo('WB', `${yesterday.getTime()}U`),
orderCount: 4,
orderAmount: 4792,
wineryRate: 0.3,
wineryAmount: 1437.6,
status: 'UNPAID',
paidAt: null,
},
});
await prisma.wineryBillItem.deleteMany({ where: { wineryBillId: wineryUnpaid.id } });
// 用极大假 orderId,避免与真实订单冲突
const fakeOrderBase = BigInt('900000000000000001');
await prisma.wineryBillItem.createMany({
data: [
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase,
orderNo: 'DKMOCK-WB-001',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 1n,
orderNo: 'DKMOCK-WB-002',
deliveryType: 'LOCAL',
payAmount: 1198,
wineryAmount: 359.4,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 2n,
orderNo: 'DKMOCK-WB-003',
deliveryType: 'CROSS_CITY',
payAmount: 1797,
wineryAmount: 539.1,
paidAt: yesterday,
},
{
wineryBillId: wineryUnpaid.id,
orderId: fakeOrderBase + 3n,
orderNo: 'DKMOCK-WB-004',
deliveryType: 'LOCAL',
payAmount: 599,
wineryAmount: 179.7,
paidAt: yesterday,
},
],
});
const wineryPaid = await prisma.wineryBill.upsert({
where: { billDate: dayBefore },
create: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
billDate: dayBefore,
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('WB', `${dayBefore.getTime()}P`),
orderCount: 2,
orderAmount: 2396,
wineryRate: 0.3,
wineryAmount: 718.8,
status: 'PAID',
paidAt: yesterday,
},
});
// ─── 4. 物流对账月账单(上月挂账未结 + 本月充值已扣)───
const logisticsUnpaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
},
},
create: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
fulfillmentProviderId: provider.id,
periodStart: prevMonthStart,
periodEnd: prevMonthEnd,
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'UNPAID',
},
update: {
billNo: billNo('LB', `${prevMonthStart.getTime()}U`),
orderCount: 3,
bottleCount: 20,
logisticsAmount: 46,
settlementMethod: 'MONTHLY_CREDIT',
status: 'UNPAID',
paidAt: null,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsUnpaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 10n,
orderNo: 'DKMOCK-LB-001',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 3 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 11n,
orderNo: 'DKMOCK-LB-002',
quantity: 8,
logisticsAmount: 20, // 箱14 + 2瓶6
shippedAt: new Date(prevMonthStart.getTime() + 8 * 86400000),
},
{
logisticsBillId: logisticsUnpaid.id,
orderId: fakeOrderBase + 12n,
orderNo: 'DKMOCK-LB-003',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(prevMonthStart.getTime() + 15 * 86400000),
},
],
});
// 修正合计:14+20+14=48
await prisma.logisticsBill.update({
where: { id: logisticsUnpaid.id },
data: { bottleCount: 20, logisticsAmount: 48 },
});
const logisticsPaid = await prisma.logisticsBill.upsert({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
},
},
create: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
fulfillmentProviderId: provider.id,
periodStart: thisMonthStart,
periodEnd: thisMonthEnd,
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
pricingSnapshotJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
status: 'PAID',
paidAt: yesterday,
},
update: {
billNo: billNo('LB', `${thisMonthStart.getTime()}P`),
orderCount: 2,
bottleCount: 10,
logisticsAmount: 22,
settlementMethod: 'PREPAID',
status: 'PAID',
paidAt: yesterday,
},
});
await prisma.logisticsBillItem.deleteMany({ where: { logisticsBillId: logisticsPaid.id } });
await prisma.logisticsBillItem.createMany({
data: [
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 20n,
orderNo: 'DKMOCK-LB-101',
quantity: 4,
logisticsAmount: 10,
shippedAt: new Date(thisMonthStart.getTime() + 2 * 86400000),
},
{
logisticsBillId: logisticsPaid.id,
orderId: fakeOrderBase + 21n,
orderNo: 'DKMOCK-LB-102',
quantity: 6,
logisticsAmount: 14,
shippedAt: new Date(thisMonthStart.getTime() + 5 * 86400000),
},
],
});
// 充值流水样例
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'RECHARGE',
amount: 5000,
balanceAfter: 5000,
remark: 'Mock 预充运费',
},
});
await prisma.logisticsPrepaidLedger.create({
data: {
fulfillmentProviderId: provider.id,
type: 'DEDUCT',
amount: 22,
balanceAfter: 4978,
logisticsBillId: logisticsPaid.id,
remark: 'Mock 本月物流账单扣款',
},
});
await prisma.fulfillmentProvider.update({
where: { id: provider.id },
data: { prepaidBalance: 4978, settlementMethod: 'PREPAID' },
});
console.log('Finance mock ready:', {
store: { id: store.id.toString(), name: store.name },
partner: { id: partner.id.toString(), phone: partner.phone },
provider: { id: provider.id.toString(), code: provider.code, name: provider.name },
storeBills: [storeBillUnpaid.billNo, storeBillPaid.billNo],
partnerBills: [partnerBillAwait.billNo, partnerBillPaid.billNo],
wineryBills: [wineryUnpaid.billNo, wineryPaid.billNo],
logisticsBills: [logisticsUnpaid.billNo, logisticsPaid.billNo],
});
console.log('打开 admin:财务 → 门店/合伙人/酒厂/物流对账');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-245
View File
@@ -1,245 +0,0 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding preV1 data...');
await prisma.partnerBill.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitLedger.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderStatusLog.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.payment.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.storeAudit.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.storeMedia.deleteMany();
await prisma.store.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.cityCommissionRule.deleteMany();
await prisma.city.deleteMany();
await prisma.partner.deleteMany();
await prisma.product.deleteMany();
await prisma.storeCategory.deleteMany();
await prisma.hqAccount.deleteMany();
const partner = await prisma.partner.create({
data: {
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
},
});
const city = await prisma.city.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2,
crossMinQty: 6,
},
});
await prisma.cityCommissionRule.create({
data: {
cityId: city.id,
orderCommissionRate: 0.05,
redeemCommissionRate: 0.03,
partnerProfitRate: 0.35,
storeSettlementRate: 0.6,
},
});
const categories = await Promise.all([
prisma.storeCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.storeCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]);
const products = await Promise.all([
prisma.product.create({
data: {
skuCode: 'QX-001',
name: '杜康·白水古酿 500ml',
subtitle: '清香型 52度 礼盒装',
aromaType: 'QINGXIANG',
spec: '500ml | 52度',
price: 599,
benefitAmount: 599,
status: 'ON_SALE',
sortOrder: 1,
mainImageUrl: 'https://picsum.photos/seed/dukang1/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-002',
name: '杜康·年份陈酿(十年)',
subtitle: '清香型 42度 纯粮酿造',
aromaType: 'QINGXIANG',
spec: '500ml | 42度',
price: 880,
benefitAmount: 880,
status: 'ON_SALE',
sortOrder: 2,
mainImageUrl: 'https://picsum.photos/seed/dukang2/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-003',
name: '杜康·御享1号 珍藏版',
subtitle: '高端定制 限量发售',
aromaType: 'QINGXIANG',
spec: '500ml | 52度',
price: 1299,
benefitAmount: 1299,
status: 'ON_SALE',
sortOrder: 3,
mainImageUrl: 'https://picsum.photos/seed/dukang3/400/400',
},
}),
prisma.product.create({
data: {
skuCode: 'QX-004',
name: '杜康·经典传承',
subtitle: '清香型 纯粮固态',
aromaType: 'QINGXIANG',
spec: '500ml | 46度',
price: 399,
benefitAmount: 399,
status: 'ON_SALE',
sortOrder: 4,
mainImageUrl: 'https://picsum.photos/seed/dukang4/400/400',
},
}),
]);
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
},
});
const store1 = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: categories[0].id,
name: '郑州老城店',
phone: '13910000001',
province: '河南省',
cityName: '郑州市',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
coverUrl: 'https://picsum.photos/seed/store1/400/300',
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: '郑州老城店',
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
const store2 = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: categories[1].id,
name: '郑州美食城店',
phone: '13910000002',
province: '河南省',
cityName: '郑州市',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
coverUrl: 'https://picsum.photos/seed/store2/400/300',
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
},
});
await prisma.storeAccount.create({
data: { storeId: store1.id, phone: '13910000001', name: '郑州老城店' },
});
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
nickname: '测试用户',
cityPref: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerId: partner.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'UNPAID',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
products: products.length,
stores: 2,
testPhones: {
user: '13800000001',
store: '13910000001',
partner: '13700000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-489
View File
@@ -1,489 +0,0 @@
/**
* 概览页统计 Mock(用户/订单/合伙人/门店/核销,幂等可重复跑)
* 用法:cd server/dukang-api && pnpm prisma:seed-stats
*/
import { PrismaClient, type OrderStatus, type PayStatus } from '@prisma/client';
import { createHash, randomBytes } from 'crypto';
const prisma = new PrismaClient();
const USER_PREFIX = 'STAT';
const ORDER_PREFIX = 'STAT';
const COUPON_PREFIX = 'STATCPN';
const REDEEM_PREFIX = 'STATRD';
const STORE_NAME_PREFIX = 'STAT门店';
const PARTNER_PHONE_PREFIX = '13788';
const PROMO_CODES = [
{ code: 'STAT_A', name: '统计演示·品鉴会A', scene: 'EVENT' as const },
{ code: 'STAT_B', name: '统计演示·线下提货B', scene: 'OFFLINE_PICKUP' as const },
{ code: 'STAT_C', name: '统计演示·线上渠道C', scene: 'ONLINE_LINK' as const },
];
const CITY_DEFS = [
{ code: '410100', name: '郑州市', province: '河南省', district: '金水区' },
{ code: '410300', name: '洛阳市', province: '河南省', district: '涧西区' },
];
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function qrcodeIdFor(code: string) {
return createHash('sha256').update(`stats-mock:${code}`).digest('hex');
}
function mulberry32(seed: number) {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
async function cleanup() {
console.log('Cleaning previous STAT* mock...');
const redeemIds = (
await prisma.redeemRecord.findMany({
where: { redeemNo: { startsWith: REDEEM_PREFIX } },
select: { id: true },
})
).map((r) => r.id);
if (redeemIds.length) {
await prisma.redeemRecordAllocation.deleteMany({
where: { redeemRecordId: { in: redeemIds } },
});
await prisma.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await prisma.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
await prisma.redeemPendingRecord.deleteMany({
where: { redeemRecordId: { in: redeemIds } },
});
await prisma.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
}
await prisma.benefitCoupon.deleteMany({
where: { couponNo: { startsWith: COUPON_PREFIX } },
});
const statOrders = await prisma.order.findMany({
where: { orderNo: { startsWith: ORDER_PREFIX } },
select: { id: true },
});
const orderIds = statOrders.map((o) => o.id);
if (orderIds.length) {
await prisma.benefitCoupon.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.orderDelivery.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
await prisma.order.deleteMany({ where: { id: { in: orderIds } } });
}
const statUsers = await prisma.user.findMany({
where: { userNo: { startsWith: USER_PREFIX } },
select: { id: true },
});
const userIds = statUsers.map((u) => u.id);
if (userIds.length) {
await prisma.userPromoAttribution.deleteMany({ where: { userId: { in: userIds } } });
await prisma.userCityPreference.deleteMany({ where: { userId: { in: userIds } } });
await prisma.userAddress.deleteMany({ where: { userId: { in: userIds } } });
await prisma.benefitCoupon.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
}
await prisma.commonPromoCode.deleteMany({
where: { code: { in: PROMO_CODES.map((p) => p.code) } },
});
const statStores = await prisma.store.findMany({
where: { name: { startsWith: STORE_NAME_PREFIX } },
select: { id: true },
});
const storeIds = statStores.map((s) => s.id);
if (storeIds.length) {
await prisma.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
await prisma.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
await prisma.store.deleteMany({ where: { id: { in: storeIds } } });
}
const statPartners = await prisma.partnerAccount.findMany({
where: { phone: { startsWith: PARTNER_PHONE_PREFIX } },
select: { id: true },
});
const partnerIds = statPartners.map((p) => p.id);
if (partnerIds.length) {
await prisma.partnerBill.deleteMany({ where: { partnerAccountId: { in: partnerIds } } });
await prisma.partnerAccount.deleteMany({
where: { OR: [{ id: { in: partnerIds } }, { parentAccountId: { in: partnerIds } }] },
});
}
}
async function ensureCities() {
const cities = [];
for (const def of CITY_DEFS) {
const city = await prisma.commonCity.upsert({
where: { code: def.code },
create: {
code: def.code,
name: def.name,
province: def.province,
status: 'ACTIVE',
},
update: {
name: def.name,
province: def.province,
status: 'ACTIVE',
},
});
cities.push({ ...city, district: def.district });
}
return cities;
}
async function main() {
console.log('Seeding stats mock (users/orders/partners/stores/redeems)...');
await cleanup();
const cities = await ensureCities();
const product = await prisma.commonProductItem.findFirst({
orderBy: { id: 'asc' },
select: {
id: true,
barcode69: true,
name: true,
spec: true,
price: true,
},
});
if (!product) throw new Error('没有商品,请先 pnpm prisma:seed');
const category = await prisma.commonStoreCategory.findFirst({ orderBy: { id: 'asc' } });
const promos = [];
for (const def of PROMO_CODES) {
const promo = await prisma.commonPromoCode.create({
data: {
code: def.code,
name: def.name,
scene: def.scene,
qrcodeId: qrcodeIdFor(def.code),
status: 'ACTIVE',
scanCount: 0,
orderCount: 0,
},
});
promos.push(promo);
}
const rand = mulberry32(20260731);
const today = startOfDay(new Date());
// ── 城市合伙人 + 门店(近 30 天分散创建) ──
const createdPartners: Array<{
id: bigint;
cityId: bigint;
city: (typeof cities)[0];
}> = [];
const createdStores: Array<{
id: bigint;
cityId: bigint;
partnerAccountId: bigint;
settlementRate: number;
}> = [];
for (let i = 0; i < cities.length; i++) {
const city = cities[i];
// 每城 2 个主合伙人
for (let j = 0; j < 2; j++) {
const dayOffset = Math.floor(rand() * 28);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(9, 0, 0, 0);
const phone = `${PARTNER_PHONE_PREFIX}${String(i * 10 + j + 1).padStart(5, '0')}`;
const partner = await prisma.partnerAccount.create({
data: {
phone,
name: `${city.name}统计合伙人${j + 1}`,
companyName: `STAT${city.name}合伙人${j + 1}`,
isPrimary: 1,
status: 'ACTIVE',
cityId: city.id,
scopeType: 'CITY_WIDE',
bindingStatus: 'ACTIVE',
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
bankAccountName: `${city.name}统计合伙人${j + 1}`,
bankAccountNo: `622202${String(1000000000 + i * 10 + j)}`,
bankBranch: `${city.name}工商银行`,
createdAt,
updatedAt: createdAt,
},
});
createdPartners.push({ id: partner.id, cityId: city.id, city });
// 每位合伙人 3~5 家门店
const storeN = 3 + Math.floor(rand() * 3);
for (let k = 0; k < storeN; k++) {
const sDay = Math.floor(rand() * Math.max(1, dayOffset + 1));
const sCreated = new Date(today);
sCreated.setDate(sCreated.getDate() - sDay);
sCreated.setHours(11, Math.floor(rand() * 40), 0, 0);
const rate = 0.6;
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerAccountId: partner.id,
categoryId: category?.id,
name: `${STORE_NAME_PREFIX}-${city.name}-${j + 1}-${k + 1}`,
phone: `1399${String(100000 + i * 100 + j * 10 + k).slice(-7)}`,
province: city.province,
cityName: city.name,
district: city.district,
address: `统计路${k + 1}`,
settlementRate: rate,
status: 'OPEN',
auditStatus: 'APPROVED',
openTime: '10:00',
closeTime: '22:00',
createdAt: sCreated,
updatedAt: sCreated,
},
});
createdStores.push({
id: store.id,
cityId: city.id,
partnerAccountId: partner.id,
settlementRate: rate,
});
}
}
}
// ── 用户 + 订单 ──
const userCount = 100;
const createdUsers: Array<{
id: bigint;
cityId: bigint;
city: (typeof cities)[0];
promoId: bigint | null;
phone: string;
}> = [];
const paidOrders: Array<{
id: bigint;
userId: bigint;
cityId: bigint;
payAmount: number;
productName: string;
}> = [];
for (let i = 0; i < userCount; i++) {
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(8 + Math.floor(rand() * 12), Math.floor(rand() * 60), 0, 0);
const city = cities[Math.floor(rand() * cities.length)];
const promo = rand() < 0.7 ? promos[Math.floor(rand() * promos.length)] : null;
const seq = String(i + 1).padStart(4, '0');
const phone = `13888${String(10000 + i).slice(-5)}`;
const user = await prisma.user.create({
data: {
userNo: `${USER_PREFIX}${seq}`,
phone,
phoneVerifiedAt: createdAt,
nickname: `统计用户${seq}`,
deviceKey: `stat-device-${seq}-${randomBytes(4).toString('hex')}`,
sourceType: promo ? 'PROMO_CODE' : 'ORGANIC',
sourceRefId: promo?.id ?? null,
sourceLabel: promo?.name ?? null,
createdAt,
updatedAt: createdAt,
cityPreference: {
create: {
selectedCityCode: city.code,
selectedDistrict: city.district,
locateCityCode: city.code,
locateDistrict: city.district,
updatedAt: createdAt,
},
},
...(promo
? {
promoTouch: {
create: {
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: createdAt,
},
},
}
: {}),
},
});
createdUsers.push({
id: user.id,
cityId: city.id,
city,
promoId: promo?.id ?? null,
phone,
});
}
const statuses: Array<{ status: OrderStatus; payStatus: PayStatus }> = [
{ status: 'COMPLETED', payStatus: 'PAID' },
{ status: 'COMPLETED', payStatus: 'PAID' },
{ status: 'PENDING_SHIP', payStatus: 'PAID' },
{ status: 'SHIPPING', payStatus: 'PAID' },
{ status: 'PENDING_PAY', payStatus: 'UNPAID' },
{ status: 'CANCELLED', payStatus: 'UNPAID' },
];
let orderCount = 0;
const promoOrderInc = new Map<string, number>();
for (const u of createdUsers) {
const n = 1 + Math.floor(rand() * 3);
for (let j = 0; j < n; j++) {
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(10 + Math.floor(rand() * 10), Math.floor(rand() * 60), 0, 0);
const qty = 2 + Math.floor(rand() * 3);
const unit = Number(product.price);
const listAmount = unit * qty;
const st = statuses[Math.floor(rand() * statuses.length)];
const paid = st.payStatus === 'PAID';
orderCount += 1;
const orderNo = `${ORDER_PREFIX}${String(orderCount).padStart(6, '0')}`;
const order = await prisma.order.create({
data: {
orderNo,
userId: u.id,
cityId: u.cityId,
promoCodeId: u.promoId,
channelSource: u.promoId ? 'STATS_MOCK' : null,
status: st.status,
payStatus: st.payStatus,
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
quantity: qty,
listUnitPrice: unit,
listAmount,
productAmount: listAmount,
payAmount: listAmount,
benefitAmount: listAmount,
receiverName: `统计用户`,
receiverPhone: u.phone,
receiverAddress: `${u.city.name}${u.city.district}统计路1号`,
receiverProvince: u.city.province,
receiverCity: u.city.name,
receiverDistrict: u.city.district,
paidAt: paid ? createdAt : null,
completedAt: st.status === 'COMPLETED' ? createdAt : null,
cancelledAt: st.status === 'CANCELLED' ? createdAt : null,
createdAt,
updatedAt: createdAt,
},
});
if (paid) {
paidOrders.push({
id: order.id,
userId: u.id,
cityId: u.cityId,
payAmount: listAmount,
productName: product.name,
});
}
if (u.promoId) {
const key = u.promoId.toString();
promoOrderInc.set(key, (promoOrderInc.get(key) ?? 0) + 1);
}
}
}
for (const [id, inc] of promoOrderInc) {
await prisma.commonPromoCode.update({
where: { id: BigInt(id) },
data: { orderCount: { increment: inc }, scanCount: { increment: Math.floor(inc * 1.5) } },
});
}
// ── 权益券 + 核销(约 80 笔,分散近 30 天) ──
const storesByCity = new Map<string, typeof createdStores>();
for (const s of createdStores) {
const key = s.cityId.toString();
const list = storesByCity.get(key) ?? [];
list.push(s);
storesByCity.set(key, list);
}
let redeemCount = 0;
const redeemTarget = Math.min(80, paidOrders.length);
for (let i = 0; i < redeemTarget; i++) {
const order = paidOrders[i];
const cityStores = storesByCity.get(order.cityId.toString()) ?? createdStores;
if (!cityStores.length) continue;
const store = cityStores[Math.floor(rand() * cityStores.length)];
const dayOffset = Math.floor(rand() * 30);
const createdAt = new Date(today);
createdAt.setDate(createdAt.getDate() - dayOffset);
createdAt.setHours(14 + Math.floor(rand() * 6), Math.floor(rand() * 60), 0, 0);
const amount = Math.round((40 + rand() * 200) * 100) / 100;
const settleAmount = Math.round(amount * store.settlementRate * 100) / 100;
const coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `${COUPON_PREFIX}${String(i + 1).padStart(5, '0')}`,
userId: order.userId,
orderId: order.id,
totalAmount: order.payAmount,
usedAmount: amount,
balance: Math.max(0, order.payAmount - amount),
status: amount >= order.payAmount ? 'USED_UP' : 'ACTIVE',
sourceProduct: order.productName,
createdAt,
updatedAt: createdAt,
},
});
redeemCount += 1;
await prisma.redeemRecord.create({
data: {
redeemNo: `${REDEEM_PREFIX}${String(redeemCount).padStart(5, '0')}`,
userId: order.userId,
couponId: coupon.id,
storeId: store.id,
amount,
settleAmount,
createdAt,
},
});
}
console.log(
`Done: cities=${cities.length}, promos=${promos.length}, partners=${createdPartners.length}, ` +
`stores=${createdStores.length}, users=${createdUsers.length}, orders=${orderCount}, redeems=${redeemCount}`,
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-964
View File
@@ -1,964 +0,0 @@
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();
});
@@ -1,89 +0,0 @@
/** 商品详情模板默认种子(与 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;
@@ -1,7 +0,0 @@
-- 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';
@@ -1,44 +0,0 @@
/**
* 将 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());
@@ -1,46 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient();
async function main() {
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.upsert({
where: { code: tpl.code },
create: {
code: tpl.code,
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
status: 'ACTIVE',
},
update: {
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
},
});
}
console.log(`Upserted ${DEFAULT_PRODUCT_DETAIL_TEMPLATES.length} product detail templates`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-17
View File
@@ -1,17 +0,0 @@
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;