init
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=3000
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@dukang/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"lint": "echo ok",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prisma:seed": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dukang/domain": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@nestjs/bullmq": "^10.2.0",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.0",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
============================================================
|
||||
-- 杜康好客 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_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,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
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),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_common_promo_code_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
|
||||
|
||||
-- ===================== PARTNER(先于 city/store) =============================
|
||||
|
||||
DROP TABLE IF EXISTS partner_partner;
|
||||
CREATE TABLE partner_partner (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
company_name VARCHAR(128) NOT NULL,
|
||||
address VARCHAR(256) NOT NULL,
|
||||
contact_phone VARCHAR(20) NOT NULL,
|
||||
contract_no VARCHAR(64) DEFAULT NULL COMMENT '合同编号',
|
||||
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,
|
||||
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_partner_partner_phone (contact_phone)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人主体';
|
||||
|
||||
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',
|
||||
partner_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
local_min_qty INT NOT NULL DEFAULT 2,
|
||||
cross_min_qty INT NOT NULL DEFAULT 6,
|
||||
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),
|
||||
KEY idx_common_city_partner (partner_id),
|
||||
CONSTRAINT fk_common_city_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
|
||||
|
||||
DROP TABLE IF EXISTS common_city_commission_rule;
|
||||
CREATE TABLE common_city_commission_rule (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
city_id BIGINT UNSIGNED NOT NULL,
|
||||
order_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
redeem_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
partner_profit_rate DECIMAL(5,4) NOT NULL DEFAULT 0.3500,
|
||||
store_settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_common_city_commission_city (city_id),
|
||||
CONSTRAINT fk_common_city_commission_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE
|
||||
) 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,
|
||||
partner_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,
|
||||
is_primary TINYINT NOT NULL DEFAULT 0,
|
||||
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
|
||||
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_partner_account_phone (phone),
|
||||
KEY idx_partner_account_partner (partner_id),
|
||||
CONSTRAINT fk_partner_account_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(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 partner_bill;
|
||||
CREATE TABLE partner_bill (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
bill_no VARCHAR(32) NOT NULL,
|
||||
partner_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_partner_status (partner_id, status),
|
||||
CONSTRAINT fk_partner_bill_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(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,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
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),
|
||||
KEY idx_user_user_source (source_type, source_ref_id),
|
||||
KEY idx_user_user_referrer (referrer_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_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,
|
||||
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 (partner_id),
|
||||
CONSTRAINT fk_store_store_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_store_store_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(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,
|
||||
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 '待付款过期时间',
|
||||
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),
|
||||
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='用户行为埋点日志';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -0,0 +1,990 @@
|
||||
// 杜康好客 · V2.1 数据模型
|
||||
// 约束说明见 doc/数据库设计2-杜康好客.md
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── 枚举 ─────────────────────────────────────────────
|
||||
|
||||
enum ClientApp {
|
||||
USER_MINI
|
||||
USER_H5
|
||||
PARTNER_MINI
|
||||
PARTNER_H5
|
||||
HQ_MINI
|
||||
SHOP_H5
|
||||
}
|
||||
|
||||
enum AccountStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum HqAdminRole {
|
||||
SUPER_ADMIN
|
||||
OPS
|
||||
FINANCE
|
||||
CUSTOMER_SERVICE
|
||||
}
|
||||
|
||||
enum PartnerStaffRole {
|
||||
PARTNER
|
||||
INTERNAL
|
||||
PROMOTER
|
||||
}
|
||||
|
||||
enum CityStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum AromaType {
|
||||
QINGXIANG
|
||||
JIANGXIANG
|
||||
NONGXIANG
|
||||
}
|
||||
|
||||
enum ProductStatus {
|
||||
DRAFT
|
||||
ON_SALE
|
||||
OFF_SALE
|
||||
}
|
||||
|
||||
enum StoreStatus {
|
||||
OPEN
|
||||
PAUSED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum StoreAuditType {
|
||||
NEW
|
||||
UPDATE
|
||||
}
|
||||
|
||||
enum StoreAuditStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum DeliveryType {
|
||||
LOCAL
|
||||
CROSS_CITY
|
||||
}
|
||||
|
||||
enum OrderType {
|
||||
NORMAL
|
||||
RESHIPMENT
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING_PAY
|
||||
PENDING_SHIP
|
||||
OUT_WAREHOUSE
|
||||
SHIPPING
|
||||
PENDING_RECEIVE
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
REFUNDING
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING
|
||||
SUCCESS
|
||||
FAILED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum RefundStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
PROCESSING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum InterceptStatus {
|
||||
PENDING
|
||||
INTERCEPTING
|
||||
SUCCESS
|
||||
FAILED
|
||||
REDELIVERING
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
enum BenefitCouponStatus {
|
||||
ACTIVE
|
||||
USED_UP
|
||||
VOID
|
||||
}
|
||||
|
||||
enum BenefitLedgerType {
|
||||
GRANT
|
||||
REDEEM
|
||||
REFUND_VOID
|
||||
ADJUST
|
||||
}
|
||||
|
||||
enum RedeemTokenStatus {
|
||||
ACTIVE
|
||||
USED
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StorePayoutStatus {
|
||||
PENDING
|
||||
PAID
|
||||
}
|
||||
|
||||
enum PartnerBillStatus {
|
||||
DRAFT
|
||||
PENDING_CONFIRM
|
||||
CONFIRMED
|
||||
PAID
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum WithdrawalStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
PAID
|
||||
}
|
||||
|
||||
enum PromoCodeStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
}
|
||||
|
||||
enum AlertType {
|
||||
ORDER_TIMEOUT
|
||||
DELIVERY_DELAY
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum AlertStatus {
|
||||
OPEN
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
enum AfterSaleType {
|
||||
REFUND
|
||||
RESHIPMENT
|
||||
}
|
||||
|
||||
enum AfterSaleStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// ─── 微信应用配置 ─────────────────────────────────────
|
||||
|
||||
model WxAppConfig {
|
||||
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("wx_app_configs")
|
||||
}
|
||||
|
||||
// ─── C端用户(phone 唯一主键,微信字段辅助)──────────
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userNo String @unique @map("user_no") @db.VarChar(20)
|
||||
phone String @unique @db.VarChar(20)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
nickname String? @db.VarChar(64)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||
status Int @default(1) @db.TinyInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
addresses UserAddress[]
|
||||
cityPref UserCityPreference?
|
||||
orders Order[]
|
||||
benefitCoupons BenefitCoupon[]
|
||||
benefitLedgers BenefitLedger[]
|
||||
redeemRecords RedeemRecord[]
|
||||
promoTouch UserPromoAttribution?
|
||||
eventLogs EventLog[]
|
||||
|
||||
@@index([wxOpenId])
|
||||
@@index([wxUnionId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
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_addresses")
|
||||
}
|
||||
|
||||
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_preferences")
|
||||
}
|
||||
|
||||
// ─── B端账号(三表分离)──────────────────────────────
|
||||
|
||||
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)
|
||||
|
||||
@@index([wxOpenId])
|
||||
@@map("store_accounts")
|
||||
}
|
||||
|
||||
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_accounts")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
storeAudits StoreAudit[]
|
||||
operationLogs OperationLog[]
|
||||
afterSales AfterSaleTicket[]
|
||||
refunds Refund[]
|
||||
|
||||
@@index([wxOpenId])
|
||||
@@index([adminRole])
|
||||
@@map("hq_accounts")
|
||||
}
|
||||
|
||||
// ─── 开城与合伙人 ─────────────────────────────────────
|
||||
|
||||
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)
|
||||
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 City[]
|
||||
accounts PartnerAccount[]
|
||||
stores Store[]
|
||||
bills PartnerBill[]
|
||||
withdrawals PartnerWithdrawal[]
|
||||
contracts PartnerContract[]
|
||||
orderCommissions OrderCommission[]
|
||||
|
||||
@@index([contactPhone])
|
||||
@@map("partners")
|
||||
}
|
||||
|
||||
model City {
|
||||
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 CityCommissionRule?
|
||||
stores Store[]
|
||||
orders Order[]
|
||||
|
||||
@@index([partnerId])
|
||||
@@index([status])
|
||||
@@map("cities")
|
||||
}
|
||||
|
||||
model CityCommissionRule {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
|
||||
orderCommissionRate Decimal @map("order_commission_rate") @db.Decimal(5, 4)
|
||||
redeemCommissionRate Decimal @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 City @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("city_commission_rules")
|
||||
}
|
||||
|
||||
model PartnerContract {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
contractNo String @map("contract_no") @db.VarChar(64)
|
||||
fileUrl String @map("file_url") @db.VarChar(512)
|
||||
signedAt DateTime @map("signed_at") @db.DateTime(3)
|
||||
expireAt DateTime? @map("expire_at") @db.DateTime(3)
|
||||
status String @default("ACTIVE") @db.VarChar(16)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([partnerId])
|
||||
@@map("partner_contracts")
|
||||
}
|
||||
|
||||
// ─── 商品 ─────────────────────────────────────────────
|
||||
|
||||
model Product {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
skuCode String @unique @map("sku_code") @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")
|
||||
mainImageUrl String @map("main_image_url") @db.VarChar(512)
|
||||
detailContent Json? @map("detail_content")
|
||||
carouselUrls Json? @map("carousel_urls")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
orderItems OrderItem[]
|
||||
|
||||
@@index([status, aromaType])
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
// ─── 门店 ─────────────────────────────────────────────
|
||||
|
||||
model StoreCategory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
|
||||
stores Store[]
|
||||
|
||||
@@map("store_categories")
|
||||
}
|
||||
|
||||
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") @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
|
||||
coverUrl String? @map("cover_url") @db.VarChar(512)
|
||||
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 City @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
category StoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
account StoreAccount?
|
||||
media StoreMedia[]
|
||||
audits StoreAudit[]
|
||||
redeemRecords RedeemRecord[]
|
||||
storePayouts StorePayout[]
|
||||
ratings StoreRating[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerId])
|
||||
@@map("stores")
|
||||
}
|
||||
|
||||
model StoreMedia {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
mediaType String @map("media_type") @db.VarChar(16)
|
||||
url String @db.VarChar(512)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([storeId])
|
||||
@@map("store_media")
|
||||
}
|
||||
|
||||
model StoreAudit {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
auditType StoreAuditType @map("audit_type")
|
||||
status StoreAuditStatus @default(PENDING)
|
||||
submitData Json? @map("submit_data")
|
||||
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
|
||||
submittedAt DateTime @default(now()) @map("submitted_at") @db.DateTime(3)
|
||||
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
reviewer HqAccount? @relation(fields: [reviewerId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([storeId, status])
|
||||
@@index([reviewerId])
|
||||
@@map("store_audits")
|
||||
}
|
||||
|
||||
// ─── 推广码 ───────────────────────────────────────────
|
||||
|
||||
model PromoCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
status PromoCodeStatus @default(ACTIVE)
|
||||
wxQrcodeUrl String? @map("wx_qrcode_url") @db.VarChar(512)
|
||||
scanCount Int @default(0) @map("scan_count")
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
orders Order[]
|
||||
attributions UserPromoAttribution[]
|
||||
|
||||
@@map("promo_codes")
|
||||
}
|
||||
|
||||
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 PromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([promoCodeId])
|
||||
@@map("user_promo_attributions")
|
||||
}
|
||||
|
||||
// ─── 订单与支付 ─────────────────────────────────────
|
||||
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
|
||||
freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
|
||||
freightPayType String? @map("freight_pay_type") @db.VarChar(8)
|
||||
payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
|
||||
benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
|
||||
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 City @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
originOrder Order? @relation("ReshipmentOrder", fields: [originOrderId], references: [id], onDelete: SetNull)
|
||||
reshipments Order[] @relation("ReshipmentOrder")
|
||||
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
||||
items OrderItem[]
|
||||
statusLogs OrderStatusLog[]
|
||||
delivery OrderDelivery?
|
||||
payment Payment?
|
||||
refunds Refund[]
|
||||
benefitCoupons BenefitCoupon[]
|
||||
intercepts DeliveryIntercept[]
|
||||
afterSales AfterSaleTicket[]
|
||||
commissions OrderCommission[]
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([cityId, createdAt])
|
||||
@@index([receiverPhone])
|
||||
@@index([originOrderId])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||
productName String @map("product_name") @db.VarChar(128)
|
||||
productSpec String @map("product_spec") @db.VarChar(128)
|
||||
productImage String @map("product_image") @db.VarChar(512)
|
||||
unitPrice Decimal @map("unit_price") @db.Decimal(10, 2)
|
||||
quantity Int
|
||||
subtotal Decimal @db.Decimal(10, 2)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([orderId])
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
model OrderStatusLog {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||
fromStatus String? @map("from_status") @db.VarChar(32)
|
||||
toStatus String @map("to_status") @db.VarChar(32)
|
||||
operator String? @db.VarChar(64)
|
||||
remark String? @db.VarChar(256)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orderId])
|
||||
@@map("order_status_logs")
|
||||
}
|
||||
|
||||
model OrderDelivery {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||
provider String @db.VarChar(16)
|
||||
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)
|
||||
rawPayload Json? @map("raw_payload")
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("order_deliveries")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||
paymentNo String @unique @map("payment_no") @db.VarChar(32)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status PaymentStatus @default(PENDING)
|
||||
wxTransactionId String? @unique @map("wx_transaction_id") @db.VarChar(64)
|
||||
wxPrepayId String? @map("wx_prepay_id") @db.VarChar(64)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
notifyPayload Json? @map("notify_payload")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model Refund {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||
refundNo String @unique @map("refund_no") @db.VarChar(32)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
reason String? @db.VarChar(512)
|
||||
status RefundStatus @default(PENDING)
|
||||
wxRefundId String? @map("wx_refund_id") @db.VarChar(64)
|
||||
benefitAdjust Decimal? @map("benefit_adjust") @db.Decimal(10, 2)
|
||||
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
|
||||
approvedAt DateTime? @map("approved_at") @db.DateTime(3)
|
||||
refundedAt DateTime? @map("refunded_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
||||
operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([orderId])
|
||||
@@index([operatorId])
|
||||
@@map("refunds")
|
||||
}
|
||||
|
||||
model DeliveryIntercept {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||
status InterceptStatus @default(PENDING)
|
||||
oldAddress Json @map("old_address")
|
||||
newAddress Json @map("new_address")
|
||||
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
|
||||
interceptAt DateTime? @map("intercept_at") @db.DateTime(3)
|
||||
redeliveryOrderId BigInt? @map("redelivery_order_id") @db.UnsignedBigInt
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([orderId, status])
|
||||
@@map("delivery_intercepts")
|
||||
}
|
||||
|
||||
// ─── 好客权益 ─────────────────────────────────────────
|
||||
|
||||
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 @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)
|
||||
ledgers BenefitLedger[]
|
||||
redeemRecords RedeemRecord[]
|
||||
redeemTokens RedeemToken[]
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([orderId])
|
||||
@@map("benefit_coupons")
|
||||
}
|
||||
|
||||
model BenefitLedger {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
type BenefitLedgerType
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
balanceAfter Decimal @map("balance_after") @db.Decimal(10, 2)
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
remark String? @db.VarChar(256)
|
||||
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)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([couponId])
|
||||
@@map("benefit_ledgers")
|
||||
}
|
||||
|
||||
// ─── 核销 ─────────────────────────────────────────────
|
||||
|
||||
model RedeemToken {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
token String @unique @db.VarChar(64)
|
||||
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)
|
||||
status RedeemTokenStatus @default(ACTIVE)
|
||||
expireAt DateTime @map("expire_at") @db.DateTime(3)
|
||||
usedAt DateTime? @map("used_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([couponId])
|
||||
@@map("redeem_tokens")
|
||||
}
|
||||
|
||||
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)
|
||||
tokenId BigInt? @map("token_id") @db.UnsignedBigInt
|
||||
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)
|
||||
payout StorePayout?
|
||||
rating StoreRating?
|
||||
commissions OrderCommission[]
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([userId])
|
||||
@@map("redeem_records")
|
||||
}
|
||||
|
||||
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("store_ratings")
|
||||
}
|
||||
|
||||
// ─── 结算 ─────────────────────────────────────────────
|
||||
|
||||
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])
|
||||
@@index([expectedPayAt])
|
||||
@@map("store_payouts")
|
||||
}
|
||||
|
||||
model OrderCommission {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
orderId BigInt? @map("order_id") @db.UnsignedBigInt
|
||||
redeemId BigInt? @map("redeem_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
commissionType String @map("commission_type") @db.VarChar(16)
|
||||
baseAmount Decimal @map("base_amount") @db.Decimal(10, 2)
|
||||
rate Decimal @db.Decimal(5, 4)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
billId BigInt? @map("bill_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||
redeem RedeemRecord? @relation(fields: [redeemId], references: [id], onDelete: SetNull)
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
bill PartnerBill? @relation(fields: [billId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([partnerId])
|
||||
@@index([billId])
|
||||
@@map("order_commissions")
|
||||
}
|
||||
|
||||
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 @map("order_commission") @db.Decimal(10, 2)
|
||||
redeemCommission Decimal @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)
|
||||
commissions OrderCommission[]
|
||||
|
||||
@@index([partnerId, status])
|
||||
@@map("partner_bills")
|
||||
}
|
||||
|
||||
model PartnerWithdrawal {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status WithdrawalStatus @default(PENDING)
|
||||
remark String? @db.VarChar(256)
|
||||
appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3)
|
||||
processedAt DateTime? @map("processed_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([partnerId])
|
||||
@@map("partner_withdrawals")
|
||||
}
|
||||
|
||||
// ─── 埋点 ─────────────────────────────────────────────
|
||||
|
||||
model EventLog {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
params Json?
|
||||
clientApp String? @map("client_app") @db.VarChar(32)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventName, createdAt])
|
||||
@@index([userId])
|
||||
@@map("event_logs")
|
||||
}
|
||||
|
||||
// ─── 运营与售后 ─────────────────────────────────────
|
||||
|
||||
model AfterSaleTicket {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
|
||||
type AfterSaleType
|
||||
status AfterSaleStatus @default(PENDING)
|
||||
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||
reshipmentOrderId BigInt? @map("reshipment_order_id") @db.UnsignedBigInt
|
||||
refundId BigInt? @map("refund_id") @db.UnsignedBigInt
|
||||
reason String? @db.VarChar(512)
|
||||
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
||||
operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([orderId])
|
||||
@@index([status, type])
|
||||
@@index([operatorId])
|
||||
@@map("after_sale_tickets")
|
||||
}
|
||||
|
||||
model Alert {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
alertType AlertType @map("alert_type")
|
||||
status AlertStatus @default(OPEN)
|
||||
refType String @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt @map("ref_id") @db.UnsignedBigInt
|
||||
title String @db.VarChar(128)
|
||||
content String @db.Text
|
||||
resolvedAt DateTime? @map("resolved_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([status, alertType])
|
||||
@@map("alerts")
|
||||
}
|
||||
|
||||
model OperationLog {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
|
||||
action String @db.VarChar(64)
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
detail Json?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([hqAccountId, createdAt])
|
||||
@@map("operation_logs")
|
||||
}
|
||||
|
||||
model SmsLog {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @db.VarChar(20)
|
||||
template String @db.VarChar(64)
|
||||
content String @db.Text
|
||||
status String @db.VarChar(16)
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([phone, createdAt])
|
||||
@@map("sms_logs")
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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: '0371-88880001',
|
||||
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: '0371-88880002',
|
||||
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: '13900000001', name: '老城店店长' },
|
||||
});
|
||||
|
||||
await prisma.storeAccount.create({
|
||||
data: { storeId: store2.id, phone: '13900000002', 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: 'CONFIRMED',
|
||||
confirmedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seed complete:', {
|
||||
city: city.name,
|
||||
products: products.length,
|
||||
stores: 2,
|
||||
testPhones: {
|
||||
user: '13800000001',
|
||||
store: '13900000001',
|
||||
partner: '13700000001',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 将 products.benefit_amount 同步为与 price 相同(全额好客权益)。
|
||||
* 用法:pnpm db:sync-benefit
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const products = await prisma.product.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 db: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.product.update({
|
||||
where: { id: p.id },
|
||||
data: { benefitAmount: p.price },
|
||||
});
|
||||
|
||||
const beforeLabel = before == null ? 'NULL' : `¥${before}`;
|
||||
console.log(` ${p.skuCode} ${p.name}`);
|
||||
console.log(` benefit: ${beforeLabel} → ¥${price}`);
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { IamModule } from './modules/iam/iam.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { TradeModule } from './modules/trade/trade.module';
|
||||
import { BenefitModule } from './modules/benefit/benefit.module';
|
||||
import { StoreModule } from './modules/store/store.module';
|
||||
import { RedeemModule } from './modules/redeem/redeem.module';
|
||||
import { SettlementModule } from './modules/settlement/settlement.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
HealthModule,
|
||||
IamModule,
|
||||
CatalogModule,
|
||||
TradeModule,
|
||||
BenefitModule,
|
||||
StoreModule,
|
||||
RedeemModule,
|
||||
SettlementModule,
|
||||
AnalyticsModule,
|
||||
JobsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
|
||||
export function serializeBigInt<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
const message =
|
||||
typeof res === 'string'
|
||||
? res
|
||||
: (res as { message?: string | string[] }).message || exception.message;
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: Array.isArray(message) ? message.join(', ') : message,
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(exception);
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
code: 500,
|
||||
message: 'Internal server error',
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||
|
||||
export interface AuthUser {
|
||||
actorType: string;
|
||||
actorId: bigint;
|
||||
clientApp: ClientApp;
|
||||
sub: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(protected readonly jwtService: JwtService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers.authorization as string | undefined;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing token');
|
||||
}
|
||||
try {
|
||||
const payload = this.jwtService.verify(auth.slice(7));
|
||||
const clientApp = req.headers['x-client-app'] as ClientApp;
|
||||
if (!clientApp || payload.clientApp !== clientApp) {
|
||||
throw new UnauthorizedException('Invalid client app');
|
||||
}
|
||||
const expectedActor = CLIENT_APP_ACTOR_MAP[clientApp];
|
||||
if (payload.actorType !== expectedActor) {
|
||||
throw new UnauthorizedException('Actor mismatch');
|
||||
}
|
||||
req.user = {
|
||||
actorType: payload.actorType,
|
||||
actorId: BigInt(payload.actorId),
|
||||
clientApp,
|
||||
sub: payload.sub,
|
||||
} satisfies AuthUser;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: () => new Redis(process.env.REDIS_URL || 'redis://localhost:6379'),
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: ['REDIS_CLIENT', RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
constructor(@Inject('REDIS_CLIENT') private readonly redis: Redis) {}
|
||||
|
||||
get client() {
|
||||
return this.redis;
|
||||
}
|
||||
|
||||
async setJson(key: string, value: unknown, ttlSeconds?: number) {
|
||||
const payload = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.redis.set(key, payload, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.redis.set(key, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async getJson<T>(key: string): Promise<T | null> {
|
||||
const raw = await this.redis.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
}
|
||||
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IDeliveryProvider {
|
||||
scheduleAutoAdvance(orderId: bigint): Promise<void>;
|
||||
advanceTo(orderId: bigint, targetStatus: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IDeliveryProvider } from './delivery.interface';
|
||||
import { DELIVERY_QUEUE } from '../../jobs/jobs.constants';
|
||||
|
||||
@Injectable()
|
||||
export class DeliveryMockProvider implements IDeliveryProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(@InjectQueue(DELIVERY_QUEUE) private readonly queue: Queue) {}
|
||||
|
||||
async scheduleAutoAdvance(orderId: bigint): Promise<void> {
|
||||
if (!this.config.mockDeliveryAuto) return;
|
||||
const steps = [
|
||||
{ delay: 0, status: 'OUT_WAREHOUSE' },
|
||||
{ delay: 10000, status: 'SHIPPING' },
|
||||
{ delay: 30000, status: 'PENDING_RECEIVE' },
|
||||
{ delay: 60000, status: 'COMPLETED' },
|
||||
];
|
||||
for (const step of steps) {
|
||||
await this.queue.add(
|
||||
'advance-status',
|
||||
{ orderId: orderId.toString(), targetStatus: step.status },
|
||||
{ delay: step.delay, jobId: `${orderId}-${step.status}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTo(orderId: bigint, targetStatus: string): Promise<void> {
|
||||
await this.queue.add('advance-status', {
|
||||
orderId: orderId.toString(),
|
||||
targetStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SMS_PROVIDER = 'SMS_PROVIDER';
|
||||
export const PAY_PROVIDER = 'PAY_PROVIDER';
|
||||
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
{ provide: PAY_PROVIDER, useClass: PayMockProvider },
|
||||
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||
SmsMockProvider,
|
||||
PayMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IPayProvider {
|
||||
payOrder(orderId: bigint): Promise<{ externalNo: string }>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { IPayProvider } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async payOrder(_orderId: bigint): Promise<{ externalNo: string }> {
|
||||
if (!this.config.mockPay) {
|
||||
throw new Error('Real WeChat pay not implemented in preV1');
|
||||
}
|
||||
return { externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ISmsProvider {
|
||||
send(phone: string, scene: string): Promise<void>;
|
||||
verify(phone: string, code: string, scene: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { ISmsProvider } from './sms.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SmsMockProvider implements ISmsProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async send(_phone: string, _scene: string): Promise<void> {
|
||||
if (!this.config.mockSms) {
|
||||
throw new Error('Real SMS not implemented in preV1');
|
||||
}
|
||||
}
|
||||
|
||||
async verify(phone: string, code: string, _scene: string): Promise<void> {
|
||||
if (this.config.mockSms && code === this.config.mockSmsCode) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Invalid verification code for ${phone}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
|
||||
@Processor(DELIVERY_QUEUE)
|
||||
export class DeliveryProcessor extends WorkerHost {
|
||||
constructor(private readonly tradeService: TradeService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<{ orderId: string; targetStatus: string }>) {
|
||||
await this.tradeService.applyStatusTransition(
|
||||
BigInt(job.data.orderId),
|
||||
'',
|
||||
job.data.targetStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DELIVERY_QUEUE = 'delivery-mock';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { DeliveryProcessor } from './delivery.processor';
|
||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), TradeModule],
|
||||
providers: [DeliveryProcessor],
|
||||
})
|
||||
export class JobsModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('analytics')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
@Post('events')
|
||||
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
|
||||
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async trackBatch(
|
||||
userId: bigint,
|
||||
clientApp: string,
|
||||
events: Array<{ eventName: string; params?: Record<string, unknown> }>,
|
||||
) {
|
||||
if (!events?.length) return { count: 0 };
|
||||
await this.prisma.eventLog.createMany({
|
||||
data: events.map((e) => ({
|
||||
userId,
|
||||
eventName: e.eventName,
|
||||
params: e.params as never,
|
||||
clientApp,
|
||||
})),
|
||||
});
|
||||
return { count: events.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { BenefitService } from './benefit.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('benefit')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BenefitController {
|
||||
constructor(private readonly benefitService: BenefitService) {}
|
||||
|
||||
@Get('coupons')
|
||||
coupons(@CurrentUser() user: AuthUser) {
|
||||
return this.benefitService.listCoupons(user.actorId);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.benefitService.getSummary(user.actorId);
|
||||
}
|
||||
|
||||
@Get('coupons/:id')
|
||||
coupon(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.benefitService.getCoupon(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Get('ledger')
|
||||
ledger(@CurrentUser() user: AuthUser, @Query('couponId') couponId?: string) {
|
||||
return this.benefitService.getLedger(
|
||||
user.actorId,
|
||||
couponId ? BigInt(couponId) : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitController } from './benefit.controller';
|
||||
import { BenefitService } from './benefit.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [BenefitController],
|
||||
providers: [BenefitService],
|
||||
exports: [BenefitService],
|
||||
})
|
||||
export class BenefitModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||
import { REDEEM_MAX_AMOUNT } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class BenefitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async grantOnOrderPaid(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
const item = order.items[0];
|
||||
if (!item) return null;
|
||||
|
||||
const product = await this.prisma.product.findUnique({ where: { id: item.productId } });
|
||||
const unitBenefit = calcBenefitAmount({
|
||||
price: Number(item.unitPrice),
|
||||
benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null,
|
||||
});
|
||||
const totalBenefit = unitBenefit * item.quantity;
|
||||
|
||||
const coupon = await this.prisma.benefitCoupon.create({
|
||||
data: {
|
||||
couponNo: generateCouponNo(),
|
||||
userId: order.userId,
|
||||
orderId: order.id,
|
||||
totalAmount: totalBenefit,
|
||||
balance: totalBenefit,
|
||||
sourceProduct: item.productName,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.benefitLedger.create({
|
||||
data: {
|
||||
userId: order.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'GRANT',
|
||||
amount: totalBenefit,
|
||||
balanceAfter: totalBenefit,
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
remark: '购酒赠券',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(coupon);
|
||||
}
|
||||
|
||||
async listCoupons(userId: bigint) {
|
||||
const list = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: { in: ['ACTIVE', 'USED_UP'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
async getSummary(userId: bigint) {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const summary = calcBenefitSummary(
|
||||
coupons.map((c) => Number(c.balance)),
|
||||
REDEEM_MAX_AMOUNT,
|
||||
);
|
||||
return serializeBigInt(summary);
|
||||
}
|
||||
|
||||
async getLedger(userId: bigint, couponId?: bigint) {
|
||||
const list = await this.prisma.benefitLedger.findMany({
|
||||
where: { userId, ...(couponId ? { couponId } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
async getCoupon(userId: bigint, couponId: bigint) {
|
||||
const coupon = await this.prisma.benefitCoupon.findFirst({
|
||||
where: { id: couponId, userId },
|
||||
});
|
||||
if (!coupon) return null;
|
||||
const ledgers = await this.prisma.benefitLedger.findMany({
|
||||
where: { couponId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt({ coupon, ledgers });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Controller('catalog')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalogService: CatalogService) {}
|
||||
|
||||
@Get('cities')
|
||||
cities() {
|
||||
return this.catalogService.listCities();
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
products(@Query('aromaType') aromaType?: string) {
|
||||
return this.catalogService.listProducts(aromaType);
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
product(@Param('id') id: string) {
|
||||
return this.catalogService.getProduct(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listCities() {
|
||||
const cities = await this.prisma.city.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string) {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(
|
||||
products.map((p) => ({
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getProduct(id: bigint) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) return null;
|
||||
return serializeBigInt({
|
||||
...product,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'dukang-api', version: 'prev1' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({ controllers: [HealthController] })
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
|
||||
@Controller()
|
||||
export class UserAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('auth/sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('auth/login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginUser(dto.phone, dto.code, ClientApp.USER_H5);
|
||||
}
|
||||
|
||||
@Post('auth/login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
|
||||
@Post('auth/wechat/bind-phone')
|
||||
bindPhone() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
|
||||
@Get('auth/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/auth')
|
||||
export class ShopAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginStore(dto.phone, dto.code, ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/auth')
|
||||
export class PartnerAuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('sms/send')
|
||||
sendSms(@Body() dto: SendSmsDto) {
|
||||
return this.authService.sendSms(dto.phone, dto.scene);
|
||||
}
|
||||
|
||||
@Post('login/sms')
|
||||
login(@Body() dto: LoginSmsDto) {
|
||||
return this.authService.loginPartner(dto.phone, dto.code, ClientApp.PARTNER_H5);
|
||||
}
|
||||
|
||||
@Post('login/wechat')
|
||||
wechatLogin() {
|
||||
return this.authService.wechatDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('user')
|
||||
export class UserProfileController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
profile(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotImplementedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
import { generateUserNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { ISmsProvider } from '../../integrations/sms/sms.interface';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
|
||||
) {}
|
||||
|
||||
async sendSms(phone: string, scene: string) {
|
||||
await this.smsProvider.send(phone, scene);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
|
||||
let user = await this.prisma.user.findUnique({ where: { phone } });
|
||||
if (!user) {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
phone,
|
||||
userNo: generateUserNo(),
|
||||
nickname: `用户${phone.slice(-4)}`,
|
||||
},
|
||||
});
|
||||
await this.prisma.userCityPreference.create({
|
||||
data: { userId: user.id, selectedCityCode: '410100', selectedDistrict: '郑州市' },
|
||||
});
|
||||
}
|
||||
return this.issueToken('USER', user.id, clientApp, {
|
||||
id: user.id.toString(),
|
||||
userNo: user.userNo,
|
||||
phone: user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||
nickname: user.nickname,
|
||||
hasWechat: !!user.wxOpenId,
|
||||
});
|
||||
}
|
||||
|
||||
async loginStore(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.STORE_LOGIN);
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('门店账号不存在');
|
||||
await this.prisma.storeAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return this.issueToken('STORE', account.id, clientApp, undefined, {
|
||||
id: account.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
storeName: account.store.name,
|
||||
});
|
||||
}
|
||||
|
||||
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
|
||||
await this.smsProvider.verify(phone, code, SmsScene.PARTNER_LOGIN);
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('合伙人账号不存在');
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(actorType: string, actorId: bigint) {
|
||||
if (actorType === 'USER') {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: actorId } });
|
||||
return serializeBigInt(user);
|
||||
}
|
||||
if (actorType === 'STORE') {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { store: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
if (actorType === 'PARTNER') {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
wechatDisabled() {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
private issueToken(
|
||||
actorType: string,
|
||||
actorId: bigint,
|
||||
clientApp: ClientApp,
|
||||
user?: Record<string, unknown>,
|
||||
store?: Record<string, unknown>,
|
||||
partner?: Record<string, unknown>,
|
||||
) {
|
||||
const payload = {
|
||||
sub: actorId.toString(),
|
||||
actorType,
|
||||
actorId: actorId.toString(),
|
||||
clientApp,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
actorType,
|
||||
actorId: actorId.toString(),
|
||||
user,
|
||||
store,
|
||||
partner,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SendSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
scene: string;
|
||||
}
|
||||
|
||||
export class LoginSmsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
PartnerAuthController,
|
||||
ShopAuthController,
|
||||
UserAuthController,
|
||||
UserProfileController,
|
||||
} from './auth.controller';
|
||||
import { UserAddressController } from './user-address.controller';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
IntegrationsModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
UserAuthController,
|
||||
ShopAuthController,
|
||||
PartnerAuthController,
|
||||
UserProfileController,
|
||||
UserAddressController,
|
||||
],
|
||||
providers: [AuthService, UserAddressService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class IamModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('user/addresses')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserAddressController {
|
||||
constructor(private readonly addressService: UserAddressService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.addressService.list(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.addressService.create(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.addressService.update(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.addressService.remove(user.actorId, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class UserAddressService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId: bigint) {
|
||||
const list = await this.prisma.userAddress.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
return serializeBigInt(list);
|
||||
}
|
||||
|
||||
async create(userId: bigint, body: Record<string, unknown>) {
|
||||
const isDefault = body.isDefault ? 1 : 0;
|
||||
if (isDefault) {
|
||||
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
||||
}
|
||||
const address = await this.prisma.userAddress.create({
|
||||
data: {
|
||||
userId,
|
||||
receiverName: String(body.receiverName),
|
||||
phone: String(body.phone),
|
||||
province: String(body.province),
|
||||
city: String(body.city),
|
||||
district: String(body.district),
|
||||
detail: String(body.detail),
|
||||
isDefault,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(address);
|
||||
}
|
||||
|
||||
async update(userId: bigint, id: bigint, body: Record<string, unknown>) {
|
||||
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new NotFoundException('地址不存在');
|
||||
if (body.isDefault) {
|
||||
await this.prisma.userAddress.updateMany({ where: { userId }, data: { isDefault: 0 } });
|
||||
}
|
||||
const address = await this.prisma.userAddress.update({
|
||||
where: { id },
|
||||
data: {
|
||||
receiverName: body.receiverName ? String(body.receiverName) : undefined,
|
||||
phone: body.phone ? String(body.phone) : undefined,
|
||||
province: body.province ? String(body.province) : undefined,
|
||||
city: body.city ? String(body.city) : undefined,
|
||||
district: body.district ? String(body.district) : undefined,
|
||||
detail: body.detail ? String(body.detail) : undefined,
|
||||
isDefault: body.isDefault ? 1 : undefined,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(address);
|
||||
}
|
||||
|
||||
async remove(userId: bigint, id: bigint) {
|
||||
const existing = await this.prisma.userAddress.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new NotFoundException('地址不存在');
|
||||
await this.prisma.userAddress.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('tokens')
|
||||
createToken(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.createToken(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Get('tokens/:token')
|
||||
getToken(@Param('token') token: string) {
|
||||
return this.redeemService.getToken(token);
|
||||
}
|
||||
|
||||
@Post('ratings')
|
||||
rating(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.redeemService.submitRating(user.actorId, body as never);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/redeem')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
}
|
||||
|
||||
@Get('records')
|
||||
records(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => SettlementModule)],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
})
|
||||
export class RedeemModule {}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
calcRedeemSettleAmount,
|
||||
generateRedeemNo,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
} from '@dukang/domain';
|
||||
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
|
||||
@Injectable()
|
||||
export class RedeemService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly settlementService: SettlementService,
|
||||
) {}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
let allocations: Array<{ couponId: string; amount: number }>;
|
||||
|
||||
if (body.couponId) {
|
||||
const coupon = await this.prisma.benefitCoupon.findFirst({
|
||||
where: { id: BigInt(body.couponId), userId, status: 'ACTIVE' },
|
||||
});
|
||||
if (!coupon) throw new NotFoundException('券不存在');
|
||||
const balance = Number(coupon.balance);
|
||||
const check = validateRedeemAmount(balance, body.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = [{ couponId: coupon.id.toString(), amount: body.amount }];
|
||||
} else {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const result = allocateBenefitCoupons(
|
||||
coupons.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
balance: Number(c.balance),
|
||||
createdAt: c.createdAt.getTime(),
|
||||
})),
|
||||
body.amount,
|
||||
);
|
||||
if (!result.ok) throw new BadRequestException(result.message);
|
||||
allocations = result.allocations;
|
||||
}
|
||||
|
||||
const primaryCouponId = BigInt(allocations[0].couponId);
|
||||
const token = randomBytes(16).toString('hex');
|
||||
const expireAt = new Date(Date.now() + REDEEM_TOKEN_TTL_SECONDS * 1000);
|
||||
|
||||
await this.prisma.redeemToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId,
|
||||
couponId: primaryCouponId,
|
||||
storeId: body.storeId ? BigInt(body.storeId) : null,
|
||||
amount: body.amount,
|
||||
expireAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.redis.setJson(
|
||||
`redeem:token:${token}`,
|
||||
{
|
||||
userId: userId.toString(),
|
||||
couponId: primaryCouponId.toString(),
|
||||
amount: body.amount,
|
||||
storeId: body.storeId ?? null,
|
||||
allocations,
|
||||
},
|
||||
REDEEM_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return { token, expireAt, amount: body.amount };
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
const cached = await this.redis.getJson<Record<string, unknown>>(`redeem:token:${token}`);
|
||||
if (!cached) throw new NotFoundException('核销码已过期');
|
||||
return cached;
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<{
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
}>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
const allocations =
|
||||
cached.allocations ??
|
||||
(cached.couponId
|
||||
? [{ couponId: cached.couponId, amount: cached.amount }]
|
||||
: []);
|
||||
if (allocations.length === 0) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
|
||||
const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0);
|
||||
if (Math.abs(allocSum - cached.amount) > 0.001) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await this.prisma.benefitCoupon.findUnique({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
if (!coupon) throw new BadRequestException('券不存在');
|
||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
}
|
||||
|
||||
const amount = Number(cached.amount);
|
||||
const cityRule = await this.prisma.cityCommissionRule.findFirst({
|
||||
where: { city: { stores: { some: { id: account.storeId } } } },
|
||||
});
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
const record = await this.prisma.$transaction(async (tx) => {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.benefitLedger.create({
|
||||
data: {
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType: 'STORE',
|
||||
refId: account.storeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.redeemToken.updateMany({
|
||||
where: { token: body.token },
|
||||
data: { status: 'USED', usedAt: new Date(), storeId: account.storeId },
|
||||
});
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
|
||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||
await this.redis.del(`redeem:token:${body.token}`);
|
||||
|
||||
return serializeBigInt(record);
|
||||
}
|
||||
|
||||
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getShopDashboard(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const records = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId, createdAt: { gte: start } },
|
||||
});
|
||||
const todayCount = records.length;
|
||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const recent = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId: account.storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
});
|
||||
return serializeBigInt({
|
||||
store: account.store,
|
||||
todayCount,
|
||||
todayAmount,
|
||||
recentRecords: recent,
|
||||
});
|
||||
}
|
||||
|
||||
async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) {
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: { id: BigInt(body.redeemRecordId), userId },
|
||||
});
|
||||
if (!record) throw new NotFoundException('核销记录不存在');
|
||||
const rating = await this.prisma.storeRating.create({
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
storeId: record.storeId,
|
||||
serviceScore: body.serviceScore,
|
||||
envScore: body.envScore,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(rating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Controller('partner/settlement')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SettlementController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get('bills')
|
||||
bills(@CurrentUser() user: AuthUser) {
|
||||
return this.settlementService.listPartnerBills(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
async me(@CurrentUser() user: AuthUser) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: user.actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
return {
|
||||
id: account.id.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
companyName: account.partner.companyName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { PartnerMeController, SettlementController } from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [SettlementController, PartnerMeController],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
export class SettlementModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async createStorePayout(
|
||||
redeemRecordId: bigint,
|
||||
storeId: bigint,
|
||||
redeemAmount: number,
|
||||
payoutAmount: number,
|
||||
settlementRate: number,
|
||||
) {
|
||||
const expectedPayAt = new Date();
|
||||
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
||||
const payout = await this.prisma.storePayout.create({
|
||||
data: {
|
||||
redeemRecordId,
|
||||
storeId,
|
||||
redeemAmount,
|
||||
payoutAmount,
|
||||
settlementRate,
|
||||
status: 'PENDING',
|
||||
expectedPayAt,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { StoreService } from './store.service';
|
||||
import { RedeemService } from '../redeem/redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('stores')
|
||||
export class PublicStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('cityCode') cityCode?: string) {
|
||||
return this.storeService.listOpenStores(cityCode);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.storeService.getStore(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerListStores(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.storeService.createStore(user.actorId, body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerDashboardController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.partnerDashboard(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
info(@CurrentUser() user: AuthUser) {
|
||||
return this.storeService.getShopStore(user.actorId);
|
||||
}
|
||||
|
||||
@Put('status')
|
||||
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
|
||||
return this.storeService.updateShopStatus(user.actorId, body.status);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopDashboardController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Get()
|
||||
async dashboard(@CurrentUser() user: AuthUser) {
|
||||
return this.redeemService.getShopDashboard(user.actorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { StoreService } from './store.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
PartnerStoreController,
|
||||
PublicStoreController,
|
||||
ShopDashboardController,
|
||||
ShopStoreController,
|
||||
} from './store.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => RedeemModule)],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PartnerStoreController,
|
||||
PartnerDashboardController,
|
||||
ShopStoreController,
|
||||
ShopDashboardController,
|
||||
],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
const where: Record<string, unknown> = { status: 'OPEN' };
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.city.findFirst({ where: { code: cityCode } });
|
||||
if (city) where.cityId = city.id;
|
||||
}
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: where as never,
|
||||
include: { category: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores);
|
||||
}
|
||||
|
||||
async getStore(id: bigint) {
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id, status: 'OPEN' },
|
||||
include: { category: true, media: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async partnerListStores(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
include: { category: true, audits: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores);
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const city = await this.prisma.city.findFirst({ where: { partnerId: account.partnerId } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerId: account.partnerId,
|
||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||
name: String(body.name),
|
||||
phone: String(body.phone),
|
||||
province: String(body.province ?? '河南省'),
|
||||
cityName: String(body.city ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
coverUrl: body.coverUrl ? String(body.coverUrl) : null,
|
||||
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
|
||||
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
|
||||
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
|
||||
openTime: body.openTime ? String(body.openTime) : '10:00',
|
||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||
},
|
||||
});
|
||||
|
||||
const audit = await this.prisma.storeAudit.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
auditType: 'NEW',
|
||||
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||||
submitData: body as never,
|
||||
reviewedAt: this.config.autoApproveStore ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
phone: String(body.accountPhone ?? body.phone),
|
||||
name: String(body.accountName ?? body.name),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt({ store, audit });
|
||||
}
|
||||
|
||||
async getShopStore(storeAccountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: { include: { category: true } } },
|
||||
});
|
||||
return serializeBigInt(account.store);
|
||||
}
|
||||
|
||||
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id: account.storeId },
|
||||
data: { status },
|
||||
});
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async partnerDashboard(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const [storeCount, orderCount] = await Promise.all([
|
||||
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
||||
this.prisma.order.count({
|
||||
where: { city: { partnerId: account.partnerId } },
|
||||
}),
|
||||
]);
|
||||
return { storeCount, orderCount, companyName: account.partner.companyName };
|
||||
}
|
||||
|
||||
private async getPartnerAccount(partnerAccountId: bigint) {
|
||||
return this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { TradeService } from './trade.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('trade/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.tradeService.preview(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.tradeService.createOrder(user.actorId, body as never);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('tab') tab = 'all',
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listOrders(user.actorId, tab, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/pay')
|
||||
pay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.payOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/address')
|
||||
updateAddress(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.tradeService.updateAddress(user.actorId, BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerOrderController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.tradeService.listPartnerOrders(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.getPartnerOrder(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/mock-advance-delivery')
|
||||
mockAdvance(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { targetStatus: string },
|
||||
) {
|
||||
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { TradeController, PartnerOrderController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
|
||||
controllers: [TradeController, PartnerOrderController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
export class TradeModule {}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
calcBenefitAmount,
|
||||
generateOrderNo,
|
||||
orderTabToStatuses,
|
||||
validateMinPurchase,
|
||||
} from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
|
||||
@Injectable()
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly benefitService: BenefitService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
const city = await this.prisma.city.findFirst({ where: { status: 'ACTIVE' } });
|
||||
if (!city) throw new BadRequestException('暂无开城城市');
|
||||
|
||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
|
||||
if (body.addressId) {
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (address && address.city !== city.name && address.city !== '郑州市') {
|
||||
deliveryType = 'CROSS_CITY';
|
||||
}
|
||||
}
|
||||
|
||||
const check = validateMinPurchase(
|
||||
deliveryType,
|
||||
body.quantity,
|
||||
city.localMinQty,
|
||||
city.crossMinQty,
|
||||
);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
|
||||
const unitPrice = Number(product.price);
|
||||
const productAmount = unitPrice * body.quantity;
|
||||
const benefitPerUnit = calcBenefitAmount({
|
||||
price: unitPrice,
|
||||
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
product: serializeBigInt(product),
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
|
||||
freightPayType: deliveryType === 'CROSS_CITY' ? 'COD' : null,
|
||||
payAmount: productAmount,
|
||||
benefitAmount: benefitPerUnit * body.quantity,
|
||||
city: serializeBigInt(city),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(
|
||||
userId: bigint,
|
||||
body: {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId: string;
|
||||
},
|
||||
) {
|
||||
const preview = await this.preview(userId, body);
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (!address) throw new BadRequestException('请选择收货地址');
|
||||
|
||||
const product = await this.prisma.product.findUniqueOrThrow({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const city = await this.prisma.city.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
userId,
|
||||
cityId: city.id,
|
||||
status: 'PENDING_PAY',
|
||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
|
||||
receiverName: address.receiverName,
|
||||
receiverPhone: address.phone,
|
||||
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
|
||||
receiverProvince: address.province,
|
||||
receiverCity: address.city,
|
||||
receiverDistrict: address.district,
|
||||
productAmount: preview.productAmount,
|
||||
freightAmount: preview.freightAmount,
|
||||
freightPayType: preview.freightPayType,
|
||||
payAmount: preview.payAmount,
|
||||
benefitAmount: preview.benefitAmount,
|
||||
payExpireAt,
|
||||
items: {
|
||||
create: {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productSpec: product.spec,
|
||||
productImage: product.mainImageUrl,
|
||||
unitPrice: product.price,
|
||||
quantity: body.quantity,
|
||||
subtotal: preview.productAmount,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
create: {
|
||||
paymentNo: `PAY${orderNo}`,
|
||||
amount: preview.payAmount,
|
||||
status: 'PENDING',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { items: true, payment: true },
|
||||
});
|
||||
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async payOrder(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
include: { items: true, payment: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status !== 'PENDING_PAY') {
|
||||
throw new BadRequestException('订单状态不可支付');
|
||||
}
|
||||
|
||||
const { externalNo } = await this.payProvider.payOrder(orderId);
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.payment.update({
|
||||
where: { orderId: order.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
paidAt: now,
|
||||
wxTransactionId: externalNo,
|
||||
},
|
||||
});
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: 'PENDING_SHIP', paidAt: now },
|
||||
});
|
||||
await tx.orderStatusLog.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'PENDING_SHIP',
|
||||
operator: 'MOCK_PAY',
|
||||
},
|
||||
});
|
||||
await tx.orderDelivery.create({
|
||||
data: { orderId: order.id, provider: 'MOCK' },
|
||||
});
|
||||
});
|
||||
|
||||
await this.benefitService.grantOnOrderPaid(order.id);
|
||||
await this.deliveryProvider.scheduleAutoAdvance(order.id);
|
||||
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
|
||||
const statuses = orderTabToStatuses(tab);
|
||||
const where = {
|
||||
userId,
|
||||
...(statuses ? { status: { in: statuses as never[] } } : {}),
|
||||
};
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: true, benefitCoupons: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOrder(userId: bigint, orderId: bigint) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, userId },
|
||||
include: {
|
||||
items: true,
|
||||
delivery: true,
|
||||
payment: true,
|
||||
benefitCoupons: true,
|
||||
statusLogs: { orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async updateAddress(userId: bigint, orderId: bigint, body: Record<string, unknown>) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前状态不可修改地址');
|
||||
}
|
||||
const updated = await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
receiverName: String(body.receiverName ?? order.receiverName),
|
||||
receiverPhone: String(body.receiverPhone ?? order.receiverPhone),
|
||||
receiverProvince: String(body.receiverProvince ?? order.receiverProvince),
|
||||
receiverCity: String(body.receiverCity ?? order.receiverCity),
|
||||
receiverDistrict: String(body.receiverDistrict ?? order.receiverDistrict),
|
||||
receiverAddress: String(body.receiverAddress ?? order.receiverAddress),
|
||||
},
|
||||
});
|
||||
await this.prisma.orderStatusLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
fromStatus: order.status,
|
||||
toStatus: order.status,
|
||||
operator: 'USER',
|
||||
remark: '修改收货地址',
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const where = { cityId: { in: cityIds } };
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: true, delivery: true, user: { select: { phone: true, nickname: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
|
||||
include: { items: true, delivery: true, statusLogs: true, user: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return serializeBigInt(order);
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
city: { partnerId: account.partnerId },
|
||||
},
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
await this.applyStatusTransition(order.id, order.status, targetStatus);
|
||||
return this.getPartnerOrder(partnerAccountId, orderId);
|
||||
}
|
||||
|
||||
async applyStatusTransition(orderId: bigint, fromStatus: string, targetStatus: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) return;
|
||||
const currentStatus = fromStatus || order.status;
|
||||
const now = new Date();
|
||||
const data: Record<string, unknown> = { status: targetStatus };
|
||||
const deliveryData: Record<string, unknown> = {};
|
||||
|
||||
if (targetStatus === 'OUT_WAREHOUSE') deliveryData.outWarehouseAt = now;
|
||||
if (targetStatus === 'SHIPPING') {
|
||||
deliveryData.shippingAt = now;
|
||||
data.shippedAt = now;
|
||||
}
|
||||
if (targetStatus === 'COMPLETED') {
|
||||
deliveryData.deliveredAt = now;
|
||||
data.completedAt = now;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({ where: { id: orderId }, data: data as never });
|
||||
if (Object.keys(deliveryData).length) {
|
||||
await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never });
|
||||
}
|
||||
await tx.orderStatusLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
fromStatus: currentStatus,
|
||||
toStatus: targetStatus,
|
||||
operator: 'MOCK',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user