diff --git a/README.md b/README.md index ef9ec5e..8eabd17 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ pnpm install cp server/dukang-api/.env.example server/dukang-api/.env pnpm db:generate cd server/dukang-api -npx prisma db push -pnpm prisma:seed +npx prisma db push # v3.1 首次切换加 --accept-data-loss +pnpm prisma:seed # seed-v31.ts ``` ### 4. 启动服务 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 3e64608..4555702 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -16,6 +16,7 @@ import DeliveriesPage from './pages/DeliveriesPage'; import HqAccountsPage from './pages/HqAccountsPage'; import CitiesPage from './pages/CitiesPage'; import StoreMediaPage from './pages/StoreMediaPage'; +import ProductsPage from './pages/ProductsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index f116527..cb88539 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -20,6 +20,7 @@ const { Header, Sider, Content } = Layout; const MENU_ITEMS: MenuProps['items'] = [ { key: '/', icon: , label: '概览' }, { key: '/users', icon: , label: '用户' }, + { key: '/products', icon: , label: '商品' }, { key: '/orders', icon: , label: '订单' }, { key: 'stores-group', diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 2638f01..b716ee5 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -44,6 +44,18 @@ export const MEDIA_TYPE_LABELS: Record = { VIDEO: '视频', }; +export const PRODUCT_STATUS_LABELS: Record = { + DRAFT: '草稿', + ON_SALE: '在售', + OFF_SALE: '下架', +}; + +export const AROMA_TYPE_LABELS: Record = { + QINGXIANG: '清香型', + JIANGXIANG: '酱香型', + NONGXIANG: '浓香型', +}; + export const LEDGER_TYPE_LABELS: Record = { GRANT: '发放', REDEEM: '核销', diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx new file mode 100644 index 0000000..6f996b1 --- /dev/null +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { + Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request, type Paginated } from '../lib/api'; +import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + skuCode: string; + barcode69: string; + name: string; + subtitle?: string; + aromaType: string; + spec: string; + price: number; + benefitAmount: number; + status: string; + sortOrder: number; + mainImageUrl?: string | null; + createdAt: string; +}; + +export default function ProductsPage() { + const [form] = Form.useForm(); + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/products', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.status) qs.set('status', filters.status); + if (filters.aromaType) qs.set('aromaType', filters.aromaType); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + + const columns: ColumnsType = [ + { title: 'SKU', dataIndex: 'skuCode', width: 90 }, + { title: '商品名', dataIndex: 'name', width: 180, ellipsis: true }, + { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, + { title: '规格', dataIndex: 'spec', width: 120, ellipsis: true }, + { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, + { title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, + { title: '状态', dataIndex: 'status', width: 80, render: (s) => {PRODUCT_STATUS_LABELS[s] || s} }, + { title: '排序', dataIndex: 'sortOrder', width: 60 }, + { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ + 商品管理 + + +
{ setFilters(v); setPage(1); }}> + + + ({ value, label }))} /> + + +
+ { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( + + )}> + {detail && ( + <> + + {String(detail.skuCode)} + {String(detail.barcode69)} + {AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)} + +
+ + + + + + + + + + )} +
+ setCreateOpen(false)} onOk={async () => { + const v = await createForm.validateFields(); + await request('/admin/products', { method: 'POST', body: JSON.stringify(v) }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + }} width={520}> +
+ + + + + + + + + + + +
+ + ); +} diff --git a/apps/h5-user/src/pages/AddressListPage.tsx b/apps/h5-user/src/pages/AddressListPage.tsx index 2fb390d..177c1fd 100644 --- a/apps/h5-user/src/pages/AddressListPage.tsx +++ b/apps/h5-user/src/pages/AddressListPage.tsx @@ -24,6 +24,7 @@ function formatAddress(a: Address) { } export default function AddressListPage() { + const [list, setList] = useState([]); const [pendingAddress, setPendingAddress] = useState
(null); const [savingOrderAddress, setSavingOrderAddress] = useState(false); const [params] = useSearchParams(); diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 98f7f25..fea1968 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -10,7 +10,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:validate": "prisma validate", - "prisma:seed": "ts-node --transpile-only prisma/seed-prev1.ts", + "prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts", + "prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts", "prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts" }, "dependencies": { diff --git a/server/dukang-api/prisma/V31_MIGRATION.md b/server/dukang-api/prisma/V31_MIGRATION.md new file mode 100644 index 0000000..650d419 --- /dev/null +++ b/server/dukang-api/prisma/V31_MIGRATION.md @@ -0,0 +1,282 @@ +# V3.1 Schema 迁移清单 + +> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`) +> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`) + +## 激活步骤(P0) + +```bash +cd server/dukang-api +# 1. 备份并切换 schema +cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份 +cp prisma/schema.v31.prisma prisma/schema.prisma + +# 2. 开发库建议删库重建 +mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..." +# 或:mysql < prisma/init_v3.sql + +# 3. 生成 Client +pnpm db:generate +npx prisma db push # 或 prisma migrate dev --name v31_init + +# 4. 新 seed(待编写 seed-v31.ts) +pnpm prisma:seed +``` + +--- + +## 表对照(27 张 v3.1) + +| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 | +|-------------|--------------|------------|------| +| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 | +| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL) | +| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** | +| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** | +| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` | +| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 | +| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` | +| `common_city` | `CommonCity` | `cities` / `City` | 重命名 | +| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 | +| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 | +| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 | +| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 | +| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 | +| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 | +| `user_address` | `UserAddress` | `user_addresses` | 重命名 | +| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 | +| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 | +| `store_store` | `Store` | `stores` | `cover_url` → `cover_resource_id` | +| `store_account` | `StoreAccount` | `store_accounts` | 重命名 | +| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 | +| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` | +| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 | +| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 | +| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 | +| `store_payout` | `StorePayout` | `store_payouts` | 重命名 | +| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** | +| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** | + +### 删除的表(v3.1 不再存在) + +| 旧表 | 替代方案 | +|------|----------| +| `store_media` | `common_resource`(`owner_type=STORE`) | +| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT | +| `order_items` | `user_order` 内嵌快照字段 | +| `order_status_logs` | `common_event(ORDER_STATUS)` | +| `payments` | `log_third_party` + `user_order.pay_*` | +| `refunds` | `common_ticket(REFUND)` | +| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT | +| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` | +| `redeem_tokens` | **仅 Redis** | +| `order_commissions` | `partner_bill` 汇总(无明细表) | +| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) | +| `store_audits` | `common_event(STORE_AUDIT)` | +| `after_sale_tickets` | `common_ticket` | +| `alerts` | `common_ticket(ALERT)` | +| `operation_logs` | `common_event(HQ_OPERATION)` | +| `event_logs` | `common_event` + `log_user_analytics` | + +### preV1 扩展字段(已并入 v3.1) + +| 字段 | 表 | 说明 | +|------|-----|------| +| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 | +| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 | + +~~以下字段在切换后丢失~~ → **已保留** + +--- + +## Prisma Client 调用变更速查 + +| 旧调用 | v3.1 调用 | +|--------|-----------| +| `prisma.product` | `prisma.commonProductItem` | +| `prisma.city` | `prisma.commonCity` | +| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` | +| `prisma.storeCategory` | `prisma.commonStoreCategory` | +| `prisma.promoCode` | `prisma.commonPromoCode` | +| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery`) | +| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon`) | +| `prisma.benefitLedger` | `prisma.commonEvent`(`eventType=BENEFIT_LEDGER`) | +| `prisma.redeemToken` | **删除**,改 Redis | +| `prisma.storeMedia` | `prisma.commonResource` | +| `prisma.storeAudit` | `prisma.commonEvent`(`eventType=STORE_AUDIT`) | +| `prisma.payment` | `prisma.logThirdParty` | +| `prisma.orderStatusLog` | `prisma.commonEvent`(`eventType=ORDER_STATUS`) | +| `prisma.orderItem` | **删除**,读写 `order` 快照字段 | + +> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。 + +--- + +## 后端模块影响面 + +### P0 — 基础设施 + +| 路径 | 影响 | 工作量 | +|------|------|--------| +| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ | +| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ | +| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ | +| `prisma/sync-benefit-to-price.ts` | `product` → `commonProductItem` | 低 | +| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 | + +### P1 — common 模块(新建) + +| 路径 | 说明 | +|------|------| +| `src/modules/common/common.module.ts` | **新建** | +| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD | +| `src/modules/common/resource.controller.ts` | `/common/resources/*` | +| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 | +| `src/modules/common/ticket.service.ts` | 工单 CRUD | + +### P2 — IAM + +| 路径 | 关键改动 | +|------|----------| +| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 | +| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 | +| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 | +| `common/guards/super-admin.guard.ts` | 无大变 | + +### P3 — catalog + +| 路径 | 关键改动 | +|------|----------| +| `modules/catalog/catalog.service.ts` | `city`→`commonCity`,`product`→`commonProductItem`;返回 `coverResource.url` | + +### P4 — trade(改动最大) + +| 路径 | 关键改动 | +|------|----------| +| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 | +| `integrations/pay/*` | 回调改查 `log_third_party` | +| `jobs/*`(配送 Mock) | `orderDelivery` 字段对齐 | + +### P5 — benefit + +| 路径 | 关键改动 | +|------|----------| +| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create` → `commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` | + +### P6 — redeem + +| 路径 | 关键改动 | +|------|----------| +| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis;`cityCommissionRule`→`commonCityCommissionRule` | + +### P7 — store + +| 路径 | 关键改动 | +|------|----------| +| `modules/store/store.service.ts` | `storeAudit`→`commonEvent`;封面改 `coverResourceId`;`city`→`commonCity` | + +### P8 — settlement + +| 路径 | 关键改动 | +|------|----------| +| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 | + +### P9 — analytics + +| 路径 | 关键改动 | +|------|----------| +| `modules/analytics/analytics.service.ts` | `eventLog`→`logUserAnalytics` | + +### P10 — ops(HQ 后台) + +| 路径 | 关键改动 | +|------|----------| +| `modules/ops/admin-stores.service.ts` | `storeMedia`→`commonResource`;`coverUrl`→`coverResourceId`;`city`→`commonCity` | +| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` | +| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include | +| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 | +| `modules/ops/admin-cities.service.ts` | `city`→`commonCity` | +| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 | +| `modules/ops/admin-redeem.service.ts` | 表名映射 | +| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 | +| `admin-stores.controller.ts` | `/admin/store-media` → `/admin/resources` 或复用 common API | + +--- + +## 前端影响面 + +| 应用 | 影响 | +|------|------| +| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 | +| `apps/h5-shop` | 门店详情封面 URL | +| `apps/h5-partner` | 录店上传走 `/common/resources` | +| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 | +| `packages/shared-types` | 枚举与 DTO 同步 | + +--- + +## P1 进度(common 模块) + +| 项 | 状态 | +|----|------| +| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ | +| `common/event/event.helpers.ts` 权益/订单事件 | ✅ | +| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ | +| `pnpm run build` | ✅ | + +## P2–P6 进度(业务层 + 兼容层 + 后台) + +| 项 | 状态 | +|----|------| +| P2 IAM + catalog 适配 v3.1 | ✅ | +| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ | +| P4 redeem + store(Redis token + 封面资源) | ✅ | +| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ | +| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ | +| `admin/products` CRUD + HQ 商品页 | ✅ | +| `sync-benefit-to-price.ts` → `commonProductItem` | ✅ | +| `schema.prisma` ↔ `schema.v31.prisma` 同步 | ✅ | +| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ | + +### 新增 HQ API + +| 路径 | 说明 | +|------|------| +| `GET/POST /admin/products` | 商品列表/新建 | +| `GET/PUT /admin/products/:id` | 商品详情/更新 | + +### 新增 API(`/api/v1/common/*`) + +| 路径 | 说明 | +|------|------| +| `POST /common/resources/upload-token` | Mock OSS 直传凭证 | +| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD | +| `POST/GET /common/events` | 事件写入/查询 | +| `GET /common/events/timeline` | 时间线 | +| `POST/GET/PUT /common/tickets` | 工单 | +| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 | + +## 建议实施顺序 + +``` +P0 schema 切换 + seed-v31 + → P1 common 模块(resource + event) + → P2 IAM + catalog(可登录、可看商品) + → P3 trade + benefit(可下单发券) + → P4 redeem + store(可核销) + → P5 ops 后台 + settlement + → P6 前端对齐 + smoke +``` + +**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。 + +--- + +## 文件索引 + +| 文件 | 说明 | +|------|------| +| `prisma/init_v3.sql` | DDL 源 | +| `prisma/schema.v31.prisma` | **新生成**,待激活 | +| `prisma/schema.legacy-v21.prisma` | 旧版备份 | +| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活**) | diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql index ec7d61b..0812a01 100644 --- a/server/dukang-api/prisma/init_v3.sql +++ b/server/dukang-api/prisma/init_v3.sql @@ -266,7 +266,10 @@ 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, + device_key VARCHAR(36) DEFAULT NULL COMMENT '访客设备标识', + phone VARCHAR(20) DEFAULT NULL COMMENT '验机后必填;访客可为NULL', + phone_verified_at DATETIME(3) DEFAULT NULL, + merged_into_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '合并入主账号', wx_open_id VARCHAR(64) DEFAULT NULL, wx_union_id VARCHAR(64) DEFAULT NULL, nickname VARCHAR(64) DEFAULT NULL, @@ -281,8 +284,10 @@ CREATE TABLE user_user ( PRIMARY KEY (id), UNIQUE KEY uk_user_user_phone (phone), UNIQUE KEY uk_user_user_no (user_no), + UNIQUE KEY uk_user_user_device_key (device_key), KEY idx_user_user_source (source_type, source_ref_id), KEY idx_user_user_referrer (referrer_user_id), + KEY idx_user_user_merged (merged_into_user_id), KEY idx_user_user_wx_open (wx_open_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户'; @@ -426,6 +431,16 @@ CREATE TABLE user_order ( receiver_province VARCHAR(32) NOT NULL, receiver_city VARCHAR(32) NOT NULL, receiver_district VARCHAR(32) NOT NULL, + client_ip VARCHAR(45) DEFAULT NULL COMMENT '下单时客户端IP', + ip_province VARCHAR(32) DEFAULT NULL COMMENT 'IP解析省', + ip_city VARCHAR(32) DEFAULT NULL COMMENT 'IP解析市', + ip_district VARCHAR(32) DEFAULT NULL COMMENT 'IP解析区县', + gps_province VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析省', + gps_city VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析市', + gps_district VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析区县', + gps_latitude DECIMAL(10,7) DEFAULT NULL, + gps_longitude DECIMAL(10,7) DEFAULT NULL, + gps_address VARCHAR(256) DEFAULT NULL COMMENT 'GPS逆地理地址', pay_external_no VARCHAR(64) DEFAULT NULL COMMENT '微信交易号(冗余)', paid_at DATETIME(3) DEFAULT NULL COMMENT '支付时间', shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)', @@ -442,6 +457,8 @@ CREATE TABLE user_order ( KEY idx_user_order_product (product_id), KEY idx_user_order_barcode (barcode_69), KEY idx_user_order_pay_external (pay_external_no), + KEY idx_user_order_ip_city (ip_city), + KEY idx_user_order_gps_city (gps_city), CONSTRAINT fk_user_order_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT, CONSTRAINT fk_user_order_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT, CONSTRAINT fk_user_order_origin FOREIGN KEY (origin_order_id) REFERENCES user_order(id) ON DELETE SET NULL, diff --git a/server/dukang-api/prisma/schema.legacy-v21.prisma b/server/dukang-api/prisma/schema.legacy-v21.prisma new file mode 100644 index 0000000..36ea989 --- /dev/null +++ b/server/dukang-api/prisma/schema.legacy-v21.prisma @@ -0,0 +1,1008 @@ +// 杜康好客 · 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) + deviceKey String? @unique @map("device_key") @db.VarChar(36) + phone String? @unique @db.VarChar(20) + phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3) + mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + nickname String? @db.VarChar(64) + 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) + + mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull) + mergedFrom User[] @relation("UserMerge") + addresses UserAddress[] + cityPref UserCityPreference? + orders Order[] + benefitCoupons BenefitCoupon[] + benefitLedgers BenefitLedger[] + redeemRecords RedeemRecord[] + promoTouch UserPromoAttribution? + eventLogs EventLog[] + + @@index([wxOpenId]) + @@index([wxUnionId]) + @@index([mergedIntoUserId]) + @@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) + clientIp String? @map("client_ip") @db.VarChar(45) + ipProvince String? @map("ip_province") @db.VarChar(32) + ipCity String? @map("ip_city") @db.VarChar(32) + ipDistrict String? @map("ip_district") @db.VarChar(32) + gpsProvince String? @map("gps_province") @db.VarChar(32) + gpsCity String? @map("gps_city") @db.VarChar(32) + gpsDistrict String? @map("gps_district") @db.VarChar(32) + gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7) + gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7) + gpsAddress String? @map("gps_address") @db.VarChar(256) + 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]) + @@index([ipCity]) + @@index([gpsCity]) + @@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") +} diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 36ea989..0a8334e 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -1,5 +1,6 @@ -// 杜康好客 · V2.1 数据模型 -// 约束说明见 doc/数据库设计2-杜康好客.md +// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成) +// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31 +// 旧版备份:schema.legacy-v21.prisma generator client { provider = "prisma-client-js" @@ -10,7 +11,7 @@ datasource db { url = env("DATABASE_URL") } -// ─── 枚举 ───────────────────────────────────────────── +// ─── 枚举(与 init_v3.sql COMMENT 对齐)──────────────── enum ClientApp { USER_MINI @@ -18,31 +19,63 @@ enum ClientApp { PARTNER_MINI PARTNER_H5 HQ_MINI + HQ_WEB SHOP_H5 } -enum AccountStatus { - ACTIVE - DISABLED -} - -enum HqAdminRole { - SUPER_ADMIN - OPS - FINANCE - CUSTOMER_SERVICE -} - -enum PartnerStaffRole { +enum ResourceOwnerType { + PRODUCT + STORE PARTNER - INTERNAL - PROMOTER + USER + ORDER + PROMO + HQ } -enum CityStatus { - PENDING +enum ResourceBizType { + COVER + ENV + CONTRACT + CAROUSEL + DETAIL + AVATAR + QRCODE + SIGN_PHOTO + VIDEO +} + +enum ResourceMediaType { + IMAGE + VIDEO + FILE +} + +enum ResourceStatus { ACTIVE - PAUSED + DELETED +} + +enum EventType { + STORE_AUDIT + ORDER_STATUS + BENEFIT_LEDGER + HQ_OPERATION + PROMO_TOUCH +} + +enum ActorType { + USER + STORE + PARTNER + HQ + SYSTEM +} + +enum TicketType { + REFUND + RESHIPMENT + ALERT } enum AromaType { @@ -57,26 +90,54 @@ enum ProductStatus { OFF_SALE } +enum PromoCodeStatus { + ACTIVE + DISABLED +} + +enum CityStatus { + PENDING + ACTIVE + PAUSED +} + +enum PartnerStaffRole { + PARTNER + INTERNAL + PROMOTER +} + +enum AccountStatus { + ACTIVE + DISABLED +} + +enum HqAdminRole { + SUPER_ADMIN + OPS + FINANCE + CUSTOMER_SERVICE +} + +enum PartnerBillStatus { + DRAFT + CONFIRMED + PAID +} + enum StoreStatus { OPEN PAUSED CLOSED } -enum StoreAuditType { - NEW - UPDATE -} - -enum StoreAuditStatus { - PENDING - APPROVED - REJECTED -} - -enum DeliveryType { - LOCAL - CROSS_CITY +enum UserSourceType { + ORGANIC + PROMO_CODE + SHARE_LINK + FRIEND_REFERRAL + OFFLINE_EVENT + OTHER } enum OrderType { @@ -96,29 +157,28 @@ enum OrderStatus { REFUNDED } -enum PaymentStatus { - PENDING - SUCCESS - FAILED - CLOSED +enum PayStatus { + UNPAID + PAYING + PAID + REFUNDING + REFUNDED } -enum RefundStatus { - PENDING - APPROVED - REJECTED - PROCESSING - SUCCESS - FAILED +enum DeliveryType { + LOCAL + CROSS_CITY } -enum InterceptStatus { - PENDING - INTERCEPTING - SUCCESS - FAILED - REDELIVERING - COMPLETED +enum FreightPayType { + FREE + COD +} + +enum DeliveryProvider { + XFX + LOGISTICS + MANUAL } enum BenefitCouponStatus { @@ -127,6 +187,27 @@ enum BenefitCouponStatus { VOID } +enum StorePayoutStatus { + PENDING + PAID +} + +enum ThirdPartyProvider { + WECHAT_PAY + WECHAT_REFUND + WECHAT_AUTH + WECHAT_MAP + XFX + SMS + LOGISTICS +} + +enum ThirdPartyLogStatus { + PENDING + SUCCESS + FAILED +} + enum BenefitLedgerType { GRANT REDEEM @@ -134,64 +215,9 @@ enum BenefitLedgerType { ADJUST } -enum RedeemTokenStatus { - ACTIVE - USED - EXPIRED - CANCELLED -} +// ─── COMMON ─────────────────────────────────────────── -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 { +model CommonWxAppConfig { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt clientApp ClientApp @unique @map("client_app") appId String @map("app_id") @db.VarChar(64) @@ -200,96 +226,198 @@ model WxAppConfig { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - @@map("wx_app_configs") + @@map("common_wx_app_config") } -// ─── C端用户(phone 唯一主键,微信字段辅助)────────── +model CommonResource { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + ownerType ResourceOwnerType @map("owner_type") + ownerId BigInt @map("owner_id") @db.UnsignedBigInt + bizType ResourceBizType @map("biz_type") + mediaType ResourceMediaType @default(IMAGE) @map("media_type") + ossBucket String @map("oss_bucket") @db.VarChar(64) + ossKey String @map("oss_key") @db.VarChar(256) + url String @db.VarChar(512) + fileName String? @map("file_name") @db.VarChar(128) + fileSize BigInt? @map("file_size") @db.UnsignedBigInt + mimeType String? @map("mime_type") @db.VarChar(64) + sortOrder Int @default(0) @map("sort_order") + status ResourceStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) -model User { + productCovers CommonProductItem[] @relation("ProductCover") + promoQrcodes CommonPromoCode[] @relation("PromoQrcode") + userAvatars User[] @relation("UserAvatar") + storeCovers Store[] @relation("StoreCover") + orderImages Order[] @relation("OrderProductImage") + deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto") + + @@index([ownerType, ownerId, bizType]) + @@index([status]) + @@map("common_resource") +} + +model CommonEvent { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + eventType EventType @map("event_type") + refType String @map("ref_type") @db.VarChar(32) + refId BigInt @map("ref_id") @db.UnsignedBigInt + actorType ActorType? @map("actor_type") + actorId BigInt? @map("actor_id") @db.UnsignedBigInt + status String? @db.VarChar(32) + param1 String? @db.VarChar(128) + param1Desc String? @map("param1_desc") @db.VarChar(64) + param2 String? @db.VarChar(128) + param2Desc String? @map("param2_desc") @db.VarChar(64) + param3 String? @db.VarChar(128) + param3Desc String? @map("param3_desc") @db.VarChar(64) + amount1 Decimal? @db.Decimal(10, 2) + amount2 Decimal? @db.Decimal(10, 2) + remark String? @db.VarChar(512) + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@index([refType, refId, eventType]) + @@index([eventType, createdAt]) + @@index([actorType, actorId]) + @@map("common_event") +} + +model CommonTicket { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + ticketNo String @unique @map("ticket_no") @db.VarChar(32) + ticketType TicketType @map("ticket_type") + status String @default("PENDING") @db.VarChar(32) + refType String @map("ref_type") @db.VarChar(32) + refId BigInt @map("ref_id") @db.UnsignedBigInt + operatorType ActorType? @map("operator_type") + operatorId BigInt? @map("operator_id") @db.UnsignedBigInt + param1 String? @db.VarChar(128) + param1Desc String? @map("param1_desc") @db.VarChar(64) + param2 String? @db.VarChar(128) + param2Desc String? @map("param2_desc") @db.VarChar(64) + param3 String? @db.VarChar(128) + param3Desc String? @map("param3_desc") @db.VarChar(64) + remark String? @db.VarChar(512) + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + completedAt DateTime? @map("completed_at") @db.DateTime(3) + + @@index([refType, refId]) + @@index([ticketType, status]) + @@map("common_ticket") +} + +model CommonProductItem { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + skuCode String @unique @map("sku_code") @db.VarChar(32) + barcode69 String @unique @map("barcode_69") @db.VarChar(32) + name String @db.VarChar(128) + subtitle String? @db.VarChar(256) + aromaType AromaType @map("aroma_type") + spec String @db.VarChar(128) + price Decimal @db.Decimal(10, 2) + benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) + status ProductStatus @default(DRAFT) + sortOrder Int @default(0) @map("sort_order") + coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt + detailContent Json? @map("detail_content") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull) + orders Order[] + + @@index([status, aromaType]) + @@map("common_product_item") +} + +model CommonStoreCategory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(64) + sort Int @default(0) + stores Store[] + + @@map("common_store_category") +} + +model CommonPromoCode { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(128) + status PromoCodeStatus @default(ACTIVE) + qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt + scanCount Int @default(0) @map("scan_count") + orderCount Int @default(0) @map("order_count") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull) + attributions UserPromoAttribution[] + orders Order[] + + @@map("common_promo_code") +} + +model CommonCity { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(16) + name String @db.VarChar(64) + province String @db.VarChar(32) + status CityStatus @default(PENDING) + partnerId BigInt? @map("partner_id") @db.UnsignedBigInt + localMinQty Int @default(2) @map("local_min_qty") + crossMinQty Int @default(6) @map("cross_min_qty") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull) + commissionRule CommonCityCommissionRule? + stores Store[] + orders Order[] + + @@index([partnerId]) + @@map("common_city") +} + +model CommonCityCommissionRule { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + cityId BigInt @unique @map("city_id") @db.UnsignedBigInt + orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4) + redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4) + partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4) + storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) + + @@map("common_city_commission_rule") +} + +// ─── PARTNER ────────────────────────────────────────── + +model Partner { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - userNo String @unique @map("user_no") @db.VarChar(20) - deviceKey String? @unique @map("device_key") @db.VarChar(36) - phone String? @unique @db.VarChar(20) - phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3) - mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt - wxOpenId String? @map("wx_open_id") @db.VarChar(64) - wxUnionId String? @map("wx_union_id") @db.VarChar(64) - nickname String? @db.VarChar(64) - avatarUrl String? @map("avatar_url") @db.VarChar(512) - status Int @default(1) @db.TinyInt + companyName String @map("company_name") @db.VarChar(128) + address String @db.VarChar(256) + contactPhone String @map("contact_phone") @db.VarChar(20) + contractNo String? @map("contract_no") @db.VarChar(64) + contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3) + contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3) + bankAccountName String? @map("bank_account_name") @db.VarChar(64) + bankAccountNo String? @map("bank_account_no") @db.VarChar(32) + bankBranch String? @map("bank_branch") @db.VarChar(128) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull) - mergedFrom User[] @relation("UserMerge") - addresses UserAddress[] - cityPref UserCityPreference? - orders Order[] - benefitCoupons BenefitCoupon[] - benefitLedgers BenefitLedger[] - redeemRecords RedeemRecord[] - promoTouch UserPromoAttribution? - eventLogs EventLog[] + cities CommonCity[] + accounts PartnerAccount[] + stores Store[] + bills PartnerBill[] - @@index([wxOpenId]) - @@index([wxUnionId]) - @@index([mergedIntoUserId]) - @@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") + @@index([contactPhone]) + @@map("partner_partner") } model PartnerAccount { @@ -314,9 +442,31 @@ model PartnerAccount { @@index([partnerId]) @@index([parentAccountId]) @@index([wxOpenId]) - @@map("partner_accounts") + @@map("partner_account") } +model PartnerBill { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + billNo String @unique @map("bill_no") @db.VarChar(32) + partnerId BigInt @map("partner_id") @db.UnsignedBigInt + periodStart DateTime @map("period_start") @db.DateTime(3) + periodEnd DateTime @map("period_end") @db.DateTime(3) + orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2) + redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2) + totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) + status PartnerBillStatus @default(DRAFT) + confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + + @@index([partnerId, status]) + @@map("partner_bill") +} + +// ─── HQ ─────────────────────────────────────────────── + model HqAccount { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt phone String @unique @db.VarChar(20) @@ -329,130 +479,99 @@ model HqAccount { 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") + @@map("hq_account") } -// ─── 开城与合伙人 ───────────────────────────────────── +// ─── USER ───────────────────────────────────────────── -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) +model User { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userNo String @unique @map("user_no") @db.VarChar(20) + deviceKey String? @unique @map("device_key") @db.VarChar(36) + phone String? @unique @db.VarChar(20) + phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3) + mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + nickname String? @db.VarChar(64) + avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt + status Int @default(1) @db.TinyInt + sourceType UserSourceType @default(ORGANIC) @map("source_type") + sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt + sourceLabel String? @map("source_label") @db.VarChar(128) + referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - 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[] + mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull) + mergedFrom User[] @relation("UserMerge") + referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull) + referrers User[] @relation("UserReferrer") + avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull) + addresses UserAddress[] + cityPreference UserCityPreference? + promoTouch UserPromoAttribution? orders Order[] + benefitCoupons BenefitCoupon[] + redeemRecords RedeemRecord[] - @@index([partnerId]) - @@index([status]) - @@map("cities") + @@index([sourceType, sourceRefId]) + @@index([referrerUserId]) + @@index([mergedIntoUserId]) + @@index([wxOpenId]) + @@map("user_user") } -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) +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) - city City @relation(fields: [cityId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@map("city_commission_rules") + @@index([userId]) + @@map("user_address") } -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) +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) - partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([partnerId]) - @@map("partner_contracts") + @@map("user_city_preference") } -// ─── 商品 ───────────────────────────────────────────── +model UserPromoAttribution { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt + channelName String @map("channel_name") @db.VarChar(128) + firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3) -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) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict) - orderItems OrderItem[] - - @@index([status, aromaType]) - @@map("products") + @@index([promoCodeId]) + @@map("user_promo_attribution") } -// ─── 门店 ───────────────────────────────────────────── - -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") -} +// ─── STORE ──────────────────────────────────────────── model Store { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt @@ -462,13 +581,13 @@ model Store { name String @db.VarChar(128) phone String @db.VarChar(20) province String @db.VarChar(32) - cityName String @map("city") @db.VarChar(32) + cityName String @map("city_name") @db.VarChar(32) district String @db.VarChar(32) address String @db.VarChar(256) latitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7) intro String? @db.Text - coverUrl String? @map("cover_url") @db.VarChar(512) + coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2) rating Decimal? @db.Decimal(3, 2) tags Json? @@ -481,269 +600,136 @@ model Store { 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) + cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull) account StoreAccount? - media StoreMedia[] - audits StoreAudit[] redeemRecords RedeemRecord[] - storePayouts StorePayout[] ratings StoreRating[] + payouts StorePayout[] @@index([cityId, status]) @@index([partnerId]) - @@map("stores") + @@map("store_store") } -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) +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([storeId]) - @@map("store_media") + @@map("store_account") } -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") -} - -// ─── 订单与支付 ───────────────────────────────────── +// ─── ORDER ──────────────────────────────────────────── model Order { - id BigInt @id @default(autoincrement()) @db.UnsignedBigInt - orderNo String @unique @map("order_no") @db.VarChar(32) - orderType OrderType @default(NORMAL) @map("order_type") - userId BigInt @map("user_id") @db.UnsignedBigInt - cityId BigInt @map("city_id") @db.UnsignedBigInt - status OrderStatus @default(PENDING_PAY) - 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) - clientIp String? @map("client_ip") @db.VarChar(45) - ipProvince String? @map("ip_province") @db.VarChar(32) - ipCity String? @map("ip_city") @db.VarChar(32) - ipDistrict String? @map("ip_district") @db.VarChar(32) - gpsProvince String? @map("gps_province") @db.VarChar(32) - gpsCity String? @map("gps_city") @db.VarChar(32) - gpsDistrict String? @map("gps_district") @db.VarChar(32) - gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7) - gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7) - gpsAddress String? @map("gps_address") @db.VarChar(256) - 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]) - @@index([ipCity]) - @@index([gpsCity]) - @@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 + orderNo String @unique @map("order_no") @db.VarChar(32) + orderType OrderType @default(NORMAL) @map("order_type") + userId BigInt @map("user_id") @db.UnsignedBigInt + cityId BigInt @map("city_id") @db.UnsignedBigInt + status OrderStatus @default(PENDING_PAY) + payStatus PayStatus @default(UNPAID) @map("pay_status") + deliveryType DeliveryType @map("delivery_type") + originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt + promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt + channelSource String? @map("channel_source") @db.VarChar(128) + productId BigInt @map("product_id") @db.UnsignedBigInt + barcode69 String @map("barcode_69") @db.VarChar(32) + productName String @map("product_name") @db.VarChar(128) + productSpec String @map("product_spec") @db.VarChar(128) + imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt + quantity Int + listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2) + listAmount Decimal @map("list_amount") @db.Decimal(10, 2) + discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2) + productAmount Decimal @map("product_amount") @db.Decimal(10, 2) + freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2) + freightPayType FreightPayType? @map("freight_pay_type") + payAmount Decimal @map("pay_amount") @db.Decimal(10, 2) + benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2) + receiverName String @map("receiver_name") @db.VarChar(32) + receiverPhone String @map("receiver_phone") @db.VarChar(20) + receiverAddress String @map("receiver_address") @db.Text + receiverProvince String @map("receiver_province") @db.VarChar(32) + receiverCity String @map("receiver_city") @db.VarChar(32) + receiverDistrict String @map("receiver_district") @db.VarChar(32) + clientIp String? @map("client_ip") @db.VarChar(45) + ipProvince String? @map("ip_province") @db.VarChar(32) + ipCity String? @map("ip_city") @db.VarChar(32) + ipDistrict String? @map("ip_district") @db.VarChar(32) + gpsProvince String? @map("gps_province") @db.VarChar(32) + gpsCity String? @map("gps_city") @db.VarChar(32) + gpsDistrict String? @map("gps_district") @db.VarChar(32) + gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7) + gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7) + gpsAddress String? @map("gps_address") @db.VarChar(256) + payExternalNo String? @map("pay_external_no") @db.VarChar(64) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + shippedAt DateTime? @map("shipped_at") @db.DateTime(3) + completedAt DateTime? @map("completed_at") @db.DateTime(3) + cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3) + payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3) remark String? @db.VarChar(512) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - order Order @relation(fields: [orderId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull) + reshipments Order[] @relation("OrderReshipment") + promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull) + product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict) + imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull) + delivery OrderDelivery? + benefitCoupon BenefitCoupon? - @@index([orderId, status]) - @@map("delivery_intercepts") + @@index([userId, status]) + @@index([cityId, createdAt]) + @@index([productId]) + @@index([barcode69]) + @@index([payExternalNo]) + @@index([ipCity]) + @@index([gpsCity]) + @@map("user_order") } -// ─── 好客权益 ───────────────────────────────────────── +model OrderDelivery { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + provider DeliveryProvider + providerOrderNo String? @map("provider_order_no") @db.VarChar(64) + trackingNo String? @map("tracking_no") @db.VarChar(64) + outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3) + shippingAt DateTime? @map("shipping_at") @db.DateTime(3) + deliveredAt DateTime? @map("delivered_at") @db.DateTime(3) + signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull) + + @@map("user_order_delivery") +} + +// ─── BENEFIT & REDEEM ───────────────────────────────── model BenefitCoupon { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt couponNo String @unique @map("coupon_no") @db.VarChar(32) userId BigInt @map("user_id") @db.UnsignedBigInt - orderId BigInt @map("order_id") @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2) balance Decimal @db.Decimal(10, 2) @@ -753,55 +739,12 @@ model BenefitCoupon { 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[] + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + order Order @relation(fields: [orderId], references: [id], onDelete: Restrict) 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") + @@map("user_benefit_coupon") } model RedeemRecord { @@ -812,19 +755,16 @@ model RedeemRecord { 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[] + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict) + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + rating StoreRating? + payout StorePayout? @@index([storeId, createdAt]) - @@index([userId]) - @@map("redeem_records") + @@map("user_redeem_record") } model StoreRating { @@ -838,11 +778,9 @@ model StoreRating { redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade) store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) - @@map("store_ratings") + @@map("user_store_rating") } -// ─── 结算 ───────────────────────────────────────────── - model StorePayout { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt @@ -860,149 +798,50 @@ model StorePayout { store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) @@index([storeId, status]) - @@index([expectedPayAt]) - @@map("store_payouts") + @@map("store_payout") } -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) +// ─── LOG ────────────────────────────────────────────── - 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) +model LogThirdParty { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + provider ThirdPartyProvider + scene String @db.VarChar(64) + refType String? @map("ref_type") @db.VarChar(32) + refId BigInt? @map("ref_id") @db.UnsignedBigInt + requestUrl String? @map("request_url") @db.VarChar(512) + requestBody Json? @map("request_body") + responseBody Json? @map("response_body") + externalNo String? @map("external_no") @db.VarChar(128) + amount Decimal? @db.Decimal(10, 2) + status ThirdPartyLogStatus @default(PENDING) + errorMessage String? @map("error_message") @db.VarChar(512) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) - @@index([partnerId]) - @@index([billId]) - @@map("order_commissions") + @@index([refType, refId]) + @@index([provider, scene, createdAt]) + @@index([externalNo]) + @@map("log_third_party") } -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) +model LogUserAnalytics { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt? @map("user_id") @db.UnsignedBigInt + sessionId String? @map("session_id") @db.VarChar(64) + eventName String @map("event_name") @db.VarChar(64) + clientApp ClientApp? @map("client_app") + pagePath String? @map("page_path") @db.VarChar(128) + refType String? @map("ref_type") @db.VarChar(32) + refId BigInt? @map("ref_id") @db.UnsignedBigInt + keyword String? @db.VarChar(128) + sourceType String? @map("source_type") @db.VarChar(32) + sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + @@index([userId, createdAt]) @@index([eventName, createdAt]) - @@index([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") + @@index([sessionId]) + @@index([refType, refId]) + @@map("log_user_analytics") } diff --git a/server/dukang-api/prisma/schema.v31.prisma b/server/dukang-api/prisma/schema.v31.prisma new file mode 100644 index 0000000..0a8334e --- /dev/null +++ b/server/dukang-api/prisma/schema.v31.prisma @@ -0,0 +1,847 @@ +// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成) +// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31 +// 旧版备份:schema.legacy-v21.prisma + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +// ─── 枚举(与 init_v3.sql COMMENT 对齐)──────────────── + +enum ClientApp { + USER_MINI + USER_H5 + PARTNER_MINI + PARTNER_H5 + HQ_MINI + HQ_WEB + SHOP_H5 +} + +enum ResourceOwnerType { + PRODUCT + STORE + PARTNER + USER + ORDER + PROMO + HQ +} + +enum ResourceBizType { + COVER + ENV + CONTRACT + CAROUSEL + DETAIL + AVATAR + QRCODE + SIGN_PHOTO + VIDEO +} + +enum ResourceMediaType { + IMAGE + VIDEO + FILE +} + +enum ResourceStatus { + ACTIVE + DELETED +} + +enum EventType { + STORE_AUDIT + ORDER_STATUS + BENEFIT_LEDGER + HQ_OPERATION + PROMO_TOUCH +} + +enum ActorType { + USER + STORE + PARTNER + HQ + SYSTEM +} + +enum TicketType { + REFUND + RESHIPMENT + ALERT +} + +enum AromaType { + QINGXIANG + JIANGXIANG + NONGXIANG +} + +enum ProductStatus { + DRAFT + ON_SALE + OFF_SALE +} + +enum PromoCodeStatus { + ACTIVE + DISABLED +} + +enum CityStatus { + PENDING + ACTIVE + PAUSED +} + +enum PartnerStaffRole { + PARTNER + INTERNAL + PROMOTER +} + +enum AccountStatus { + ACTIVE + DISABLED +} + +enum HqAdminRole { + SUPER_ADMIN + OPS + FINANCE + CUSTOMER_SERVICE +} + +enum PartnerBillStatus { + DRAFT + CONFIRMED + PAID +} + +enum StoreStatus { + OPEN + PAUSED + CLOSED +} + +enum UserSourceType { + ORGANIC + PROMO_CODE + SHARE_LINK + FRIEND_REFERRAL + OFFLINE_EVENT + OTHER +} + +enum OrderType { + NORMAL + RESHIPMENT +} + +enum OrderStatus { + PENDING_PAY + PENDING_SHIP + OUT_WAREHOUSE + SHIPPING + PENDING_RECEIVE + COMPLETED + CANCELLED + REFUNDING + REFUNDED +} + +enum PayStatus { + UNPAID + PAYING + PAID + REFUNDING + REFUNDED +} + +enum DeliveryType { + LOCAL + CROSS_CITY +} + +enum FreightPayType { + FREE + COD +} + +enum DeliveryProvider { + XFX + LOGISTICS + MANUAL +} + +enum BenefitCouponStatus { + ACTIVE + USED_UP + VOID +} + +enum StorePayoutStatus { + PENDING + PAID +} + +enum ThirdPartyProvider { + WECHAT_PAY + WECHAT_REFUND + WECHAT_AUTH + WECHAT_MAP + XFX + SMS + LOGISTICS +} + +enum ThirdPartyLogStatus { + PENDING + SUCCESS + FAILED +} + +enum BenefitLedgerType { + GRANT + REDEEM + REFUND_VOID + ADJUST +} + +// ─── COMMON ─────────────────────────────────────────── + +model CommonWxAppConfig { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + clientApp ClientApp @unique @map("client_app") + appId String @map("app_id") @db.VarChar(64) + appSecret String @map("app_secret") @db.VarChar(128) + mchId String? @map("mch_id") @db.VarChar(32) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + @@map("common_wx_app_config") +} + +model CommonResource { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + ownerType ResourceOwnerType @map("owner_type") + ownerId BigInt @map("owner_id") @db.UnsignedBigInt + bizType ResourceBizType @map("biz_type") + mediaType ResourceMediaType @default(IMAGE) @map("media_type") + ossBucket String @map("oss_bucket") @db.VarChar(64) + ossKey String @map("oss_key") @db.VarChar(256) + url String @db.VarChar(512) + fileName String? @map("file_name") @db.VarChar(128) + fileSize BigInt? @map("file_size") @db.UnsignedBigInt + mimeType String? @map("mime_type") @db.VarChar(64) + sortOrder Int @default(0) @map("sort_order") + status ResourceStatus @default(ACTIVE) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + productCovers CommonProductItem[] @relation("ProductCover") + promoQrcodes CommonPromoCode[] @relation("PromoQrcode") + userAvatars User[] @relation("UserAvatar") + storeCovers Store[] @relation("StoreCover") + orderImages Order[] @relation("OrderProductImage") + deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto") + + @@index([ownerType, ownerId, bizType]) + @@index([status]) + @@map("common_resource") +} + +model CommonEvent { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + eventType EventType @map("event_type") + refType String @map("ref_type") @db.VarChar(32) + refId BigInt @map("ref_id") @db.UnsignedBigInt + actorType ActorType? @map("actor_type") + actorId BigInt? @map("actor_id") @db.UnsignedBigInt + status String? @db.VarChar(32) + param1 String? @db.VarChar(128) + param1Desc String? @map("param1_desc") @db.VarChar(64) + param2 String? @db.VarChar(128) + param2Desc String? @map("param2_desc") @db.VarChar(64) + param3 String? @db.VarChar(128) + param3Desc String? @map("param3_desc") @db.VarChar(64) + amount1 Decimal? @db.Decimal(10, 2) + amount2 Decimal? @db.Decimal(10, 2) + remark String? @db.VarChar(512) + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@index([refType, refId, eventType]) + @@index([eventType, createdAt]) + @@index([actorType, actorId]) + @@map("common_event") +} + +model CommonTicket { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + ticketNo String @unique @map("ticket_no") @db.VarChar(32) + ticketType TicketType @map("ticket_type") + status String @default("PENDING") @db.VarChar(32) + refType String @map("ref_type") @db.VarChar(32) + refId BigInt @map("ref_id") @db.UnsignedBigInt + operatorType ActorType? @map("operator_type") + operatorId BigInt? @map("operator_id") @db.UnsignedBigInt + param1 String? @db.VarChar(128) + param1Desc String? @map("param1_desc") @db.VarChar(64) + param2 String? @db.VarChar(128) + param2Desc String? @map("param2_desc") @db.VarChar(64) + param3 String? @db.VarChar(128) + param3Desc String? @map("param3_desc") @db.VarChar(64) + remark String? @db.VarChar(512) + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + completedAt DateTime? @map("completed_at") @db.DateTime(3) + + @@index([refType, refId]) + @@index([ticketType, status]) + @@map("common_ticket") +} + +model CommonProductItem { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + skuCode String @unique @map("sku_code") @db.VarChar(32) + barcode69 String @unique @map("barcode_69") @db.VarChar(32) + name String @db.VarChar(128) + subtitle String? @db.VarChar(256) + aromaType AromaType @map("aroma_type") + spec String @db.VarChar(128) + price Decimal @db.Decimal(10, 2) + benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2) + status ProductStatus @default(DRAFT) + sortOrder Int @default(0) @map("sort_order") + coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt + detailContent Json? @map("detail_content") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull) + orders Order[] + + @@index([status, aromaType]) + @@map("common_product_item") +} + +model CommonStoreCategory { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(64) + sort Int @default(0) + stores Store[] + + @@map("common_store_category") +} + +model CommonPromoCode { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(32) + name String @db.VarChar(128) + status PromoCodeStatus @default(ACTIVE) + qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt + scanCount Int @default(0) @map("scan_count") + orderCount Int @default(0) @map("order_count") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull) + attributions UserPromoAttribution[] + orders Order[] + + @@map("common_promo_code") +} + +model CommonCity { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + code String @unique @db.VarChar(16) + name String @db.VarChar(64) + province String @db.VarChar(32) + status CityStatus @default(PENDING) + partnerId BigInt? @map("partner_id") @db.UnsignedBigInt + localMinQty Int @default(2) @map("local_min_qty") + crossMinQty Int @default(6) @map("cross_min_qty") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull) + commissionRule CommonCityCommissionRule? + stores Store[] + orders Order[] + + @@index([partnerId]) + @@map("common_city") +} + +model CommonCityCommissionRule { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + cityId BigInt @unique @map("city_id") @db.UnsignedBigInt + orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4) + redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4) + partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4) + storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) + + @@map("common_city_commission_rule") +} + +// ─── PARTNER ────────────────────────────────────────── + +model Partner { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + companyName String @map("company_name") @db.VarChar(128) + address String @db.VarChar(256) + contactPhone String @map("contact_phone") @db.VarChar(20) + contractNo String? @map("contract_no") @db.VarChar(64) + contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3) + contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3) + bankAccountName String? @map("bank_account_name") @db.VarChar(64) + bankAccountNo String? @map("bank_account_no") @db.VarChar(32) + bankBranch String? @map("bank_branch") @db.VarChar(128) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + cities CommonCity[] + accounts PartnerAccount[] + stores Store[] + bills PartnerBill[] + + @@index([contactPhone]) + @@map("partner_partner") +} + +model PartnerAccount { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + partnerId BigInt @map("partner_id") @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + name String @db.VarChar(64) + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + isPrimary Int @default(0) @map("is_primary") @db.TinyInt + parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt + staffRole PartnerStaffRole? @map("staff_role") + status AccountStatus @default(ACTIVE) + lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull) + children PartnerAccount[] @relation("PartnerAccountHierarchy") + + @@index([partnerId]) + @@index([parentAccountId]) + @@index([wxOpenId]) + @@map("partner_account") +} + +model PartnerBill { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + billNo String @unique @map("bill_no") @db.VarChar(32) + partnerId BigInt @map("partner_id") @db.UnsignedBigInt + periodStart DateTime @map("period_start") @db.DateTime(3) + periodEnd DateTime @map("period_end") @db.DateTime(3) + orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2) + redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2) + totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) + status PartnerBillStatus @default(DRAFT) + confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + + @@index([partnerId, status]) + @@map("partner_bill") +} + +// ─── HQ ─────────────────────────────────────────────── + +model HqAccount { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + name String @db.VarChar(64) + adminRole HqAdminRole @default(OPS) @map("admin_role") + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + status AccountStatus @default(ACTIVE) + lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + @@map("hq_account") +} + +// ─── USER ───────────────────────────────────────────── + +model User { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userNo String @unique @map("user_no") @db.VarChar(20) + deviceKey String? @unique @map("device_key") @db.VarChar(36) + phone String? @unique @db.VarChar(20) + phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3) + mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + nickname String? @db.VarChar(64) + avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt + status Int @default(1) @db.TinyInt + sourceType UserSourceType @default(ORGANIC) @map("source_type") + sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt + sourceLabel String? @map("source_label") @db.VarChar(128) + referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull) + mergedFrom User[] @relation("UserMerge") + referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull) + referrers User[] @relation("UserReferrer") + avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull) + addresses UserAddress[] + cityPreference UserCityPreference? + promoTouch UserPromoAttribution? + orders Order[] + benefitCoupons BenefitCoupon[] + redeemRecords RedeemRecord[] + + @@index([sourceType, sourceRefId]) + @@index([referrerUserId]) + @@index([mergedIntoUserId]) + @@index([wxOpenId]) + @@map("user_user") +} + +model UserAddress { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + receiverName String @map("receiver_name") @db.VarChar(32) + phone String @db.VarChar(20) + province String @db.VarChar(32) + city String @db.VarChar(32) + district String @db.VarChar(32) + detail String @db.VarChar(256) + latitude Decimal? @db.Decimal(10, 7) + longitude Decimal? @db.Decimal(10, 7) + isDefault Int @default(0) @map("is_default") @db.TinyInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("user_address") +} + +model UserCityPreference { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + selectedCityCode String? @map("selected_city_code") @db.VarChar(16) + selectedDistrict String? @map("selected_district") @db.VarChar(32) + locateCityCode String? @map("locate_city_code") @db.VarChar(16) + locateDistrict String? @map("locate_district") @db.VarChar(32) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_city_preference") +} + +model UserPromoAttribution { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt @unique @map("user_id") @db.UnsignedBigInt + promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt + channelName String @map("channel_name") @db.VarChar(128) + firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict) + + @@index([promoCodeId]) + @@map("user_promo_attribution") +} + +// ─── STORE ──────────────────────────────────────────── + +model Store { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + cityId BigInt @map("city_id") @db.UnsignedBigInt + partnerId BigInt @map("partner_id") @db.UnsignedBigInt + categoryId BigInt? @map("category_id") @db.UnsignedBigInt + name String @db.VarChar(128) + phone String @db.VarChar(20) + province String @db.VarChar(32) + cityName String @map("city_name") @db.VarChar(32) + district String @db.VarChar(32) + address String @db.VarChar(256) + latitude Decimal? @db.Decimal(10, 7) + longitude Decimal? @db.Decimal(10, 7) + intro String? @db.Text + coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt + avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2) + rating Decimal? @db.Decimal(3, 2) + tags Json? + status StoreStatus @default(PAUSED) + openTime String? @map("open_time") @db.VarChar(8) + closeTime String? @map("close_time") @db.VarChar(8) + bankAccountName String? @map("bank_account_name") @db.VarChar(64) + bankAccountNo String? @map("bank_account_no") @db.VarChar(32) + bankBranch String? @map("bank_branch") @db.VarChar(128) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) + category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull) + account StoreAccount? + redeemRecords RedeemRecord[] + ratings StoreRating[] + payouts StorePayout[] + + @@index([cityId, status]) + @@index([partnerId]) + @@map("store_store") +} + +model StoreAccount { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + storeId BigInt @unique @map("store_id") @db.UnsignedBigInt + phone String @unique @db.VarChar(20) + name String @db.VarChar(64) + wxOpenId String? @map("wx_open_id") @db.VarChar(64) + wxUnionId String? @map("wx_union_id") @db.VarChar(64) + status AccountStatus @default(ACTIVE) + lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@map("store_account") +} + +// ─── ORDER ──────────────────────────────────────────── + +model Order { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + orderNo String @unique @map("order_no") @db.VarChar(32) + orderType OrderType @default(NORMAL) @map("order_type") + userId BigInt @map("user_id") @db.UnsignedBigInt + cityId BigInt @map("city_id") @db.UnsignedBigInt + status OrderStatus @default(PENDING_PAY) + payStatus PayStatus @default(UNPAID) @map("pay_status") + deliveryType DeliveryType @map("delivery_type") + originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt + promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt + channelSource String? @map("channel_source") @db.VarChar(128) + productId BigInt @map("product_id") @db.UnsignedBigInt + barcode69 String @map("barcode_69") @db.VarChar(32) + productName String @map("product_name") @db.VarChar(128) + productSpec String @map("product_spec") @db.VarChar(128) + imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt + quantity Int + listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2) + listAmount Decimal @map("list_amount") @db.Decimal(10, 2) + discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2) + productAmount Decimal @map("product_amount") @db.Decimal(10, 2) + freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2) + freightPayType FreightPayType? @map("freight_pay_type") + payAmount Decimal @map("pay_amount") @db.Decimal(10, 2) + benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2) + receiverName String @map("receiver_name") @db.VarChar(32) + receiverPhone String @map("receiver_phone") @db.VarChar(20) + receiverAddress String @map("receiver_address") @db.Text + receiverProvince String @map("receiver_province") @db.VarChar(32) + receiverCity String @map("receiver_city") @db.VarChar(32) + receiverDistrict String @map("receiver_district") @db.VarChar(32) + clientIp String? @map("client_ip") @db.VarChar(45) + ipProvince String? @map("ip_province") @db.VarChar(32) + ipCity String? @map("ip_city") @db.VarChar(32) + ipDistrict String? @map("ip_district") @db.VarChar(32) + gpsProvince String? @map("gps_province") @db.VarChar(32) + gpsCity String? @map("gps_city") @db.VarChar(32) + gpsDistrict String? @map("gps_district") @db.VarChar(32) + gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7) + gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7) + gpsAddress String? @map("gps_address") @db.VarChar(256) + payExternalNo String? @map("pay_external_no") @db.VarChar(64) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + shippedAt DateTime? @map("shipped_at") @db.DateTime(3) + completedAt DateTime? @map("completed_at") @db.DateTime(3) + cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3) + payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3) + remark String? @db.VarChar(512) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) + originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull) + reshipments Order[] @relation("OrderReshipment") + promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull) + product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict) + imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull) + delivery OrderDelivery? + benefitCoupon BenefitCoupon? + + @@index([userId, status]) + @@index([cityId, createdAt]) + @@index([productId]) + @@index([barcode69]) + @@index([payExternalNo]) + @@index([ipCity]) + @@index([gpsCity]) + @@map("user_order") +} + +model OrderDelivery { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + provider DeliveryProvider + providerOrderNo String? @map("provider_order_no") @db.VarChar(64) + trackingNo String? @map("tracking_no") @db.VarChar(64) + outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3) + shippingAt DateTime? @map("shipping_at") @db.DateTime(3) + deliveredAt DateTime? @map("delivered_at") @db.DateTime(3) + signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull) + + @@map("user_order_delivery") +} + +// ─── BENEFIT & REDEEM ───────────────────────────────── + +model BenefitCoupon { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + couponNo String @unique @map("coupon_no") @db.VarChar(32) + userId BigInt @map("user_id") @db.UnsignedBigInt + orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) + usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2) + balance Decimal @db.Decimal(10, 2) + status BenefitCouponStatus @default(ACTIVE) + sourceProduct String @map("source_product") @db.VarChar(128) + version Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + order Order @relation(fields: [orderId], references: [id], onDelete: Restrict) + redeemRecords RedeemRecord[] + + @@index([userId, status]) + @@map("user_benefit_coupon") +} + +model RedeemRecord { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + redeemNo String @unique @map("redeem_no") @db.VarChar(32) + userId BigInt @map("user_id") @db.UnsignedBigInt + couponId BigInt @map("coupon_id") @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + amount Decimal @db.Decimal(10, 2) + settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict) + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + rating StoreRating? + payout StorePayout? + + @@index([storeId, createdAt]) + @@map("user_redeem_record") +} + +model StoreRating { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + serviceScore Int @map("service_score") @db.TinyInt + envScore Int @map("env_score") @db.TinyInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + + @@map("user_store_rating") +} + +model StorePayout { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt + storeId BigInt @map("store_id") @db.UnsignedBigInt + redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2) + payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2) + settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4) + status StorePayoutStatus @default(PENDING) + expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3) + paidAt DateTime? @map("paid_at") @db.DateTime(3) + batchNo String? @map("batch_no") @db.VarChar(32) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict) + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + + @@index([storeId, status]) + @@map("store_payout") +} + +// ─── LOG ────────────────────────────────────────────── + +model LogThirdParty { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + provider ThirdPartyProvider + scene String @db.VarChar(64) + refType String? @map("ref_type") @db.VarChar(32) + refId BigInt? @map("ref_id") @db.UnsignedBigInt + requestUrl String? @map("request_url") @db.VarChar(512) + requestBody Json? @map("request_body") + responseBody Json? @map("response_body") + externalNo String? @map("external_no") @db.VarChar(128) + amount Decimal? @db.Decimal(10, 2) + status ThirdPartyLogStatus @default(PENDING) + errorMessage String? @map("error_message") @db.VarChar(512) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@index([refType, refId]) + @@index([provider, scene, createdAt]) + @@index([externalNo]) + @@map("log_third_party") +} + +model LogUserAnalytics { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + userId BigInt? @map("user_id") @db.UnsignedBigInt + sessionId String? @map("session_id") @db.VarChar(64) + eventName String @map("event_name") @db.VarChar(64) + clientApp ClientApp? @map("client_app") + pagePath String? @map("page_path") @db.VarChar(128) + refType String? @map("ref_type") @db.VarChar(32) + refId BigInt? @map("ref_id") @db.UnsignedBigInt + keyword String? @db.VarChar(128) + sourceType String? @map("source_type") @db.VarChar(32) + sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt + extraJson Json? @map("extra_json") + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@index([userId, createdAt]) + @@index([eventName, createdAt]) + @@index([sessionId]) + @@index([refType, refId]) + @@map("log_user_analytics") +} diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts new file mode 100644 index 0000000..a336edf --- /dev/null +++ b/server/dukang-api/prisma/seed-v31.ts @@ -0,0 +1,248 @@ +import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function createMockResource( + ownerType: ResourceOwnerType, + ownerId: bigint, + bizType: ResourceBizType, + url: string, +) { + return prisma.commonResource.create({ + data: { + ownerType, + ownerId, + bizType, + mediaType: ResourceMediaType.IMAGE, + ossBucket: 'mock-dukang', + ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`, + url, + }, + }); +} + +async function main() { + console.log('Seeding v3.1 data...'); + + await prisma.logUserAnalytics.deleteMany(); + await prisma.logThirdParty.deleteMany(); + await prisma.storePayout.deleteMany(); + await prisma.storeRating.deleteMany(); + await prisma.redeemRecord.deleteMany(); + await prisma.benefitCoupon.deleteMany(); + await prisma.orderDelivery.deleteMany(); + await prisma.order.deleteMany(); + await prisma.commonEvent.deleteMany(); + await prisma.commonTicket.deleteMany(); + await prisma.userPromoAttribution.deleteMany(); + await prisma.userCityPreference.deleteMany(); + await prisma.userAddress.deleteMany(); + await prisma.user.deleteMany(); + await prisma.storeAccount.deleteMany(); + await prisma.store.deleteMany(); + await prisma.partnerBill.deleteMany(); + await prisma.partnerAccount.deleteMany(); + await prisma.commonCityCommissionRule.deleteMany(); + await prisma.commonCity.deleteMany(); + await prisma.partner.deleteMany(); + await prisma.commonProductItem.deleteMany(); + await prisma.commonStoreCategory.deleteMany(); + await prisma.commonPromoCode.deleteMany(); + await prisma.commonResource.deleteMany(); + await prisma.hqAccount.deleteMany(); + + const partner = await prisma.partner.create({ + data: { + companyName: '郑州城市合伙人', + address: '河南省郑州市金水区', + contactPhone: '13700000001', + bankAccountName: '郑州合伙人公司', + bankAccountNo: '6222021234567890', + bankBranch: '工商银行郑州分行', + }, + }); + + const city = await prisma.commonCity.create({ + data: { + code: '410100', + name: '郑州市', + province: '河南省', + status: 'ACTIVE', + partnerId: partner.id, + localMinQty: 2, + crossMinQty: 6, + }, + }); + + await prisma.commonCityCommissionRule.create({ + data: { + cityId: city.id, + orderCommissionRate: 0.05, + redeemCommissionRate: 0.03, + partnerProfitRate: 0.35, + storeSettlementRate: 0.6, + }, + }); + + const categories = await Promise.all([ + prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }), + prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }), + ]); + + const productDefs = [ + { skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' }, + { skuCode: 'QX-002', name: '杜康·年份陈酿(十年)', subtitle: '清香型 42度 纯粮酿造', price: 880, sortOrder: 2, img: 'https://picsum.photos/seed/dukang2/400/400' }, + { skuCode: 'QX-003', name: '杜康·御享1号 珍藏版', subtitle: '高端定制 限量发售', price: 1299, sortOrder: 3, img: 'https://picsum.photos/seed/dukang3/400/400' }, + { skuCode: 'QX-004', name: '杜康·经典传承', subtitle: '清香型 纯粮固态', price: 399, sortOrder: 4, img: 'https://picsum.photos/seed/dukang4/400/400' }, + ]; + + const products = []; + for (const [i, def] of productDefs.entries()) { + const product = await prisma.commonProductItem.create({ + data: { + skuCode: def.skuCode, + barcode69: `69000000000${i + 1}`, + name: def.name, + subtitle: def.subtitle, + aromaType: 'QINGXIANG', + spec: def.skuCode === 'QX-002' ? '500ml | 42度' : def.skuCode === 'QX-004' ? '500ml | 46度' : '500ml | 52度', + price: def.price, + benefitAmount: def.price, + status: 'ON_SALE', + sortOrder: def.sortOrder, + }, + }); + const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img); + await prisma.commonProductItem.update({ + where: { id: product.id }, + data: { coverResourceId: cover.id }, + }); + products.push(product); + } + + await prisma.partnerAccount.create({ + data: { + partnerId: partner.id, + phone: '13700000001', + name: '郑州合伙人主账号', + isPrimary: 1, + staffRole: 'PARTNER', + }, + }); + + const storeDefs = [ + { + name: '郑州老城店', + phone: '0371-88880001', + district: '金水区', + address: '花园路100号', + intro: '正宗河南菜,欢迎核销好客权益', + img: 'https://picsum.photos/seed/store1/400/300', + categoryId: categories[0].id, + accountPhone: '13900000001', + accountName: '老城店店长', + }, + { + name: '郑州美食城店', + phone: '0371-88880002', + district: '二七区', + address: '大学路200号', + intro: '地方特色餐饮', + img: 'https://picsum.photos/seed/store2/400/300', + categoryId: categories[1].id, + accountPhone: '13900000002', + accountName: '美食城店长', + }, + ]; + + for (const def of storeDefs) { + const store = await prisma.store.create({ + data: { + cityId: city.id, + partnerId: partner.id, + categoryId: def.categoryId, + name: def.name, + phone: def.phone, + province: '河南省', + cityName: '郑州市', + district: def.district, + address: def.address, + intro: def.intro, + status: 'OPEN', + openTime: '10:00', + closeTime: '22:00', + bankAccountName: def.name, + bankAccountNo: '6222029876543210', + bankBranch: '建设银行郑州分行', + }, + }); + const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img); + await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); + await prisma.storeAccount.create({ + data: { storeId: store.id, phone: def.accountPhone, name: def.accountName }, + }); + } + + await prisma.user.create({ + data: { + userNo: 'DK88293401', + phone: '13800000001', + phoneVerifiedAt: new Date(), + nickname: '测试用户', + cityPreference: { + create: { + selectedCityCode: '410100', + selectedDistrict: '郑州市', + locateCityCode: '410100', + locateDistrict: '金水区', + }, + }, + }, + }); + + await prisma.hqAccount.create({ + data: { + phone: '13600000001', + 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: storeDefs.length, + testPhones: { + user: '13800000001', + store: '13900000001', + partner: '13700000001', + hq: '13600000001', + }, + }); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/dukang-api/prisma/sync-benefit-to-price.ts b/server/dukang-api/prisma/sync-benefit-to-price.ts index 2bba2ad..3c76f6a 100644 --- a/server/dukang-api/prisma/sync-benefit-to-price.ts +++ b/server/dukang-api/prisma/sync-benefit-to-price.ts @@ -1,5 +1,5 @@ /** - * 将 products.benefit_amount 同步为与 price 相同(全额好客权益)。 + * 将 common_product_item.benefit_amount 同步为与 price 相同(全额好客权益)。 * 用法:pnpm db:sync-benefit */ import { PrismaClient } from '@prisma/client'; @@ -7,13 +7,13 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - const products = await prisma.product.findMany({ + const products = await prisma.commonProductItem.findMany({ select: { id: true, skuCode: true, name: true, price: true, benefitAmount: true }, orderBy: { sortOrder: 'asc' }, }); if (products.length === 0) { - console.log('No products found. Run pnpm db:seed first.'); + console.log('No products found. Run pnpm prisma:seed first.'); return; } @@ -23,7 +23,7 @@ async function main() { const price = Number(p.price); const before = p.benefitAmount != null ? Number(p.benefitAmount) : null; - await prisma.product.update({ + await prisma.commonProductItem.update({ where: { id: p.id }, data: { benefitAmount: p.price }, }); diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index f37bf46..ff79ab6 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module'; import { AnalyticsModule } from './modules/analytics/analytics.module'; import { JobsModule } from './jobs/jobs.module'; import { OpsModule } from './modules/ops/ops.module'; +import { CommonModule } from './modules/common/common.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { OpsModule } from './modules/ops/ops.module'; AnalyticsModule, JobsModule, OpsModule, + CommonModule, ], }) export class AppModule {} diff --git a/server/dukang-api/src/common/compat/v31-compat.ts b/server/dukang-api/src/common/compat/v31-compat.ts new file mode 100644 index 0000000..8b837cb --- /dev/null +++ b/server/dukang-api/src/common/compat/v31-compat.ts @@ -0,0 +1,72 @@ +import type { CommonEvent } from '@prisma/client'; + +type OrderLike = { + productName?: string; + productSpec?: string; + quantity?: number; + listUnitPrice?: unknown; + payAmount?: unknown; + payStatus?: string; + payExternalNo?: string | null; + paidAt?: Date | string | null; + imageResource?: { url?: string } | null; +}; + +export function mapOrderItemCompat(order: OrderLike) { + return { + productName: order.productName ?? '', + productSpec: order.productSpec ?? '', + productImage: order.imageResource?.url ?? '', + unitPrice: Number(order.listUnitPrice ?? 0), + quantity: order.quantity ?? 1, + }; +} + +export function mapOrderCompat(order: T) { + const payStatus = order.payStatus ?? 'UNPAID'; + return { + ...order, + items: [mapOrderItemCompat(order)], + payment: { + status: payStatus === 'PAID' ? 'SUCCESS' : payStatus, + externalNo: order.payExternalNo ?? null, + paidAt: order.paidAt ?? null, + }, + }; +} + +export function mapStoreCompat(store: T) { + return { + ...store, + coverUrl: store.coverResource?.url ?? null, + }; +} + +export function mapStatusLogCompat(events: CommonEvent[]) { + return events.map((e) => ({ + fromStatus: e.param1, + toStatus: e.param2 ?? '', + operator: e.param3, + remark: e.remark, + createdAt: e.createdAt, + })); +} + +export function mapBenefitLedgerCompat( + event: CommonEvent, + user?: { userNo?: string | null } | null, + coupon?: { couponNo?: string } | null, +) { + return { + id: event.id, + type: event.param1, + amount: event.amount1 != null ? Number(event.amount1) : 0, + balanceAfter: event.amount2 != null ? Number(event.amount2) : 0, + remark: event.remark, + createdAt: event.createdAt, + userId: event.actorId, + couponId: event.param2 ? BigInt(event.param2) : null, + user: user ? { userNo: user.userNo } : undefined, + coupon: coupon ? { couponNo: coupon.couponNo } : undefined, + }; +} diff --git a/server/dukang-api/src/common/event/event.helpers.ts b/server/dukang-api/src/common/event/event.helpers.ts new file mode 100644 index 0000000..cb2f091 --- /dev/null +++ b/server/dukang-api/src/common/event/event.helpers.ts @@ -0,0 +1,67 @@ +import type { Prisma } from '@prisma/client'; + +type BenefitLedgerType = 'GRANT' | 'REDEEM' | 'REFUND_VOID' | 'ADJUST'; + +export function buildBenefitLedgerEvent(data: { + userId: bigint; + couponId: bigint; + type: BenefitLedgerType; + amount: number; + balanceAfter: number; + refType: string; + refId?: bigint; + remark?: string; +}): Prisma.CommonEventCreateInput { + return { + eventType: 'BENEFIT_LEDGER', + refType: data.refType, + refId: data.refId ?? data.couponId, + actorType: 'USER', + actorId: data.userId, + param1: data.type, + param1Desc: 'ledger_type', + param2: data.couponId.toString(), + param2Desc: 'coupon_id', + amount1: data.amount, + amount2: data.balanceAfter, + remark: data.remark, + }; +} + +export function buildOrderStatusEvent(data: { + orderId: bigint; + fromStatus: string; + toStatus: string; + operator: string; + remark?: string; +}): Prisma.CommonEventCreateInput { + return { + eventType: 'ORDER_STATUS', + refType: 'ORDER', + refId: data.orderId, + actorType: 'SYSTEM', + param1: data.fromStatus, + param1Desc: 'from_status', + param2: data.toStatus, + param2Desc: 'to_status', + param3: data.operator, + param3Desc: 'operator', + remark: data.remark, + }; +} + +export function benefitLedgerWhere(userId?: bigint, couponId?: bigint): Prisma.CommonEventWhereInput { + return { + eventType: 'BENEFIT_LEDGER', + ...(userId ? { actorType: 'USER', actorId: userId } : {}), + ...(couponId ? { param2: couponId.toString() } : {}), + }; +} + +export function orderStatusLogWhere(orderId: bigint): Prisma.CommonEventWhereInput { + return { + eventType: 'ORDER_STATUS', + refType: 'ORDER', + refId: orderId, + }; +} diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts index 9d6df92..c38633a 100644 --- a/server/dukang-api/src/modules/analytics/analytics.service.ts +++ b/server/dukang-api/src/modules/analytics/analytics.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import type { ClientApp } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; @Injectable() @@ -11,12 +12,12 @@ export class AnalyticsService { events: Array<{ eventName: string; params?: Record }>, ) { if (!events?.length) return { count: 0 }; - await this.prisma.eventLog.createMany({ + await this.prisma.logUserAnalytics.createMany({ data: events.map((e) => ({ userId, eventName: e.eventName, - params: e.params as never, - clientApp, + extraJson: e.params as never, + clientApp: clientApp as ClientApp, })), }); return { count: events.length }; diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts index f97f370..caee503 100644 --- a/server/dukang-api/src/modules/benefit/benefit.service.ts +++ b/server/dukang-api/src/modules/benefit/benefit.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers'; @Injectable() export class BenefitService { @@ -10,17 +11,14 @@ export class BenefitService { 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 product = await this.prisma.commonProductItem.findUnique({ where: { id: order.productId } }); const unitBenefit = calcBenefitAmount({ - price: Number(item.unitPrice), + price: Number(order.listUnitPrice), benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null, }); - const totalBenefit = unitBenefit * item.quantity; + const totalBenefit = unitBenefit * order.quantity; const coupon = await this.prisma.benefitCoupon.create({ data: { @@ -29,12 +27,12 @@ export class BenefitService { orderId: order.id, totalAmount: totalBenefit, balance: totalBenefit, - sourceProduct: item.productName, + sourceProduct: order.productName, }, }); - await this.prisma.benefitLedger.create({ - data: { + await this.prisma.commonEvent.create({ + data: buildBenefitLedgerEvent({ userId: order.userId, couponId: coupon.id, type: 'GRANT', @@ -43,7 +41,7 @@ export class BenefitService { refType: 'ORDER', refId: order.id, remark: '购酒赠券', - }, + }), }); return serializeBigInt(coupon); @@ -69,8 +67,8 @@ export class BenefitService { } async getLedger(userId: bigint, couponId?: bigint) { - const list = await this.prisma.benefitLedger.findMany({ - where: { userId, ...(couponId ? { couponId } : {}) }, + const list = await this.prisma.commonEvent.findMany({ + where: benefitLedgerWhere(userId, couponId), orderBy: { createdAt: 'desc' }, }); return serializeBigInt(list); @@ -81,8 +79,8 @@ export class BenefitService { where: { id: couponId, userId }, }); if (!coupon) return null; - const ledgers = await this.prisma.benefitLedger.findMany({ - where: { couponId }, + const ledgers = await this.prisma.commonEvent.findMany({ + where: benefitLedgerWhere(undefined, couponId), orderBy: { createdAt: 'desc' }, }); return serializeBigInt({ coupon, ledgers }); diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index e127150..f60d56f 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -7,7 +7,7 @@ export class CatalogService { constructor(private readonly prisma: PrismaService) {} async listCities() { - const cities = await this.prisma.city.findMany({ + const cities = await this.prisma.commonCity.findMany({ where: { status: 'ACTIVE' }, include: { partner: { select: { companyName: true } } }, }); @@ -15,9 +15,10 @@ export class CatalogService { } async listProducts(aromaType?: string) { - const products = await this.prisma.product.findMany({ + const products = await this.prisma.commonProductItem.findMany({ where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) }, orderBy: { sortOrder: 'asc' }, + include: { coverResource: true }, }); return serializeBigInt( products.map((p) => ({ @@ -25,17 +26,22 @@ export class CatalogService { benefitAmount: p.benefitAmount ?? p.price, price: Number(p.price), benefitDisplay: Number(p.benefitAmount ?? p.price), + mainImageUrl: p.coverResource?.url ?? null, })), ); } async getProduct(id: bigint) { - const product = await this.prisma.product.findUnique({ where: { id } }); + const product = await this.prisma.commonProductItem.findUnique({ + where: { id }, + include: { coverResource: true }, + }); if (!product) return null; return serializeBigInt({ ...product, benefitAmount: product.benefitAmount ?? product.price, price: Number(product.price), + mainImageUrl: product.coverResource?.url ?? null, }); } } diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts new file mode 100644 index 0000000..67b3f55 --- /dev/null +++ b/server/dukang-api/src/modules/common/common.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { IamModule } from '../iam/iam.module'; +import { ResourceService } from './resource.service'; +import { EventService } from './event.service'; +import { TicketService } from './ticket.service'; +import { ThirdPartyLogService } from './third-party-log.service'; +import { ResourceController } from './resource.controller'; +import { EventController } from './event.controller'; +import { TicketController } from './ticket.controller'; +import { ThirdPartyLogController } from './third-party-log.controller'; + +@Module({ + imports: [IamModule], + controllers: [ResourceController, EventController, TicketController, ThirdPartyLogController], + providers: [ResourceService, EventService, TicketService, ThirdPartyLogService], + exports: [ResourceService, EventService, TicketService], +}) +export class CommonModule {} diff --git a/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts b/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts new file mode 100644 index 0000000..45a240d --- /dev/null +++ b/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts @@ -0,0 +1,178 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UploadTokenDto { + @IsString() + @IsNotEmpty() + bizType: string; + + @IsString() + @IsIn(['IMAGE', 'VIDEO', 'FILE']) + mediaType: string; + + @IsString() + @IsNotEmpty() + fileName: string; +} + +export class RegisterResourceDto { + @IsString() + @IsNotEmpty() + ownerType: string; + + @IsString() + @IsNotEmpty() + ownerId: string; + + @IsString() + @IsNotEmpty() + bizType: string; + + @IsString() + @IsIn(['IMAGE', 'VIDEO', 'FILE']) + mediaType: string; + + @IsString() + @IsNotEmpty() + ossKey: string; + + @IsString() + @IsNotEmpty() + url: string; + + @IsOptional() + @IsString() + ossBucket?: string; + + @IsOptional() + @IsString() + fileName?: string; + + @IsOptional() + sortOrder?: number; +} + +export class UpdateResourceDto { + @IsOptional() + @IsString() + url?: string; + + @IsOptional() + @IsIn(['IMAGE', 'VIDEO', 'FILE']) + mediaType?: string; + + @IsOptional() + sortOrder?: number; + + @IsOptional() + @IsIn(['ACTIVE', 'DELETED']) + status?: string; +} + +export class CreateEventDto { + @IsString() + @IsNotEmpty() + eventType: string; + + @IsString() + @IsNotEmpty() + refType: string; + + @IsString() + @IsNotEmpty() + refId: string; + + @IsOptional() + @IsString() + actorType?: string; + + @IsOptional() + @IsString() + actorId?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + param1?: string; + + @IsOptional() + @IsString() + param1Desc?: string; + + @IsOptional() + @IsString() + param2?: string; + + @IsOptional() + @IsString() + param2Desc?: string; + + @IsOptional() + @IsString() + param3?: string; + + @IsOptional() + @IsString() + param3Desc?: string; + + @IsOptional() + amount1?: number; + + @IsOptional() + amount2?: number; + + @IsOptional() + @IsString() + remark?: string; + + @IsOptional() + extraJson?: Record; +} + +export class CreateTicketDto { + @IsString() + @IsIn(['REFUND', 'RESHIPMENT', 'ALERT']) + ticketType: string; + + @IsString() + @IsNotEmpty() + refType: string; + + @IsString() + @IsNotEmpty() + refId: string; + + @IsOptional() + @IsString() + remark?: string; + + @IsOptional() + @IsString() + param1?: string; + + @IsOptional() + @IsString() + param1Desc?: string; +} + +export class UpdateTicketStatusDto { + @IsString() + @IsNotEmpty() + status: string; + + @IsOptional() + @IsString() + remark?: string; +} + +export class AssignTicketDto { + @IsString() + @IsIn(['HQ', 'PARTNER', 'SYSTEM']) + operatorType: string; + + @IsString() + @IsNotEmpty() + operatorId: string; +} diff --git a/server/dukang-api/src/modules/common/dto/common-query.dto.ts b/server/dukang-api/src/modules/common/dto/common-query.dto.ts new file mode 100644 index 0000000..374d0e4 --- /dev/null +++ b/server/dukang-api/src/modules/common/dto/common-query.dto.ts @@ -0,0 +1,93 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number = 20; +} + +export class ResourceListQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + ownerType?: string; + + @IsOptional() + @IsString() + ownerId?: string; + + @IsOptional() + @IsString() + bizType?: string; + + @IsOptional() + @IsIn(['ACTIVE', 'DELETED']) + status?: string; +} + +export class EventListQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + refType?: string; + + @IsOptional() + @IsString() + refId?: string; + + @IsOptional() + @IsString() + eventType?: string; +} + +export class EventTimelineQueryDto { + @IsString() + refType: string; + + @IsString() + refId: string; +} + +export class TicketListQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + ticketType?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + refType?: string; + + @IsOptional() + @IsString() + refId?: string; +} + +export class ThirdPartyLogQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + provider?: string; + + @IsOptional() + @IsString() + scene?: string; + + @IsOptional() + @IsString() + refType?: string; + + @IsOptional() + @IsString() + refId?: string; +} diff --git a/server/dukang-api/src/modules/common/event.controller.ts b/server/dukang-api/src/modules/common/event.controller.ts new file mode 100644 index 0000000..c057501 --- /dev/null +++ b/server/dukang-api/src/modules/common/event.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { EventService } from './event.service'; +import { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto'; +import { CreateEventDto } from './dto/common-mutate.dto'; + +@Controller('common/events') +@UseGuards(JwtAuthGuard) +export class EventController { + constructor(private readonly service: EventService) {} + + @Post() + create(@Body() dto: CreateEventDto) { + return this.service.create(dto); + } + + @Get() + list(@Query() query: EventListQueryDto) { + return this.service.list(query); + } + + @Get('timeline') + timeline(@Query() query: EventTimelineQueryDto) { + return this.service.timeline(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } +} diff --git a/server/dukang-api/src/modules/common/event.service.ts b/server/dukang-api/src/modules/common/event.service.ts new file mode 100644 index 0000000..9718cb5 --- /dev/null +++ b/server/dukang-api/src/modules/common/event.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { ActorType, EventType, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto'; +import type { CreateEventDto } from './dto/common-mutate.dto'; + +@Injectable() +export class EventService { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateEventDto) { + const event = await this.prisma.commonEvent.create({ + data: { + eventType: dto.eventType as EventType, + refType: dto.refType, + refId: BigInt(dto.refId), + actorType: dto.actorType as ActorType | undefined, + actorId: dto.actorId ? BigInt(dto.actorId) : undefined, + status: dto.status, + param1: dto.param1, + param1Desc: dto.param1Desc, + param2: dto.param2, + param2Desc: dto.param2Desc, + param3: dto.param3, + param3Desc: dto.param3Desc, + amount1: dto.amount1, + amount2: dto.amount2, + remark: dto.remark, + extraJson: dto.extraJson as Prisma.InputJsonValue | undefined, + }, + }); + return serializeBigInt(event); + } + + async list(query: EventListQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.CommonEventWhereInput = {}; + if (query.refType) where.refType = query.refType; + if (query.refId) where.refId = BigInt(query.refId); + if (query.eventType) where.eventType = query.eventType as Prisma.EnumEventTypeFilter['equals']; + + const [items, total] = await Promise.all([ + this.prisma.commonEvent.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.commonEvent.count({ where }), + ]); + return serializeBigInt({ items, total, page, pageSize }); + } + + async timeline(query: EventTimelineQueryDto) { + const items = await this.prisma.commonEvent.findMany({ + where: { refType: query.refType, refId: BigInt(query.refId) }, + orderBy: { createdAt: 'asc' }, + take: 200, + }); + return serializeBigInt(items); + } + + async detail(id: bigint) { + const event = await this.prisma.commonEvent.findUnique({ where: { id } }); + if (!event) throw new NotFoundException('事件不存在'); + return serializeBigInt(event); + } +} diff --git a/server/dukang-api/src/modules/common/resource.controller.ts b/server/dukang-api/src/modules/common/resource.controller.ts new file mode 100644 index 0000000..c46b062 --- /dev/null +++ b/server/dukang-api/src/modules/common/resource.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ResourceService } from './resource.service'; +import { ResourceListQueryDto } from './dto/common-query.dto'; +import { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto'; + +@Controller('common/resources') +@UseGuards(JwtAuthGuard) +export class ResourceController { + constructor(private readonly service: ResourceService) {} + + @Post('upload-token') + uploadToken(@Body() dto: UploadTokenDto) { + return this.service.getUploadToken(dto); + } + + @Post() + register(@Body() dto: RegisterResourceDto) { + return this.service.register(dto); + } + + @Get() + list(@Query() query: ResourceListQueryDto) { + return this.service.list(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateResourceDto) { + return this.service.update(BigInt(id), dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.service.remove(BigInt(id)); + } +} diff --git a/server/dukang-api/src/modules/common/resource.service.ts b/server/dukang-api/src/modules/common/resource.service.ts new file mode 100644 index 0000000..c0d099a --- /dev/null +++ b/server/dukang-api/src/modules/common/resource.service.ts @@ -0,0 +1,98 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { ResourceListQueryDto } from './dto/common-query.dto'; +import type { RegisterResourceDto, UpdateResourceDto, UploadTokenDto } from './dto/common-mutate.dto'; + +@Injectable() +export class ResourceService { + constructor(private readonly prisma: PrismaService) {} + + getUploadToken(dto: UploadTokenDto) { + const bucket = process.env.OSS_BUCKET || 'mock-dukang'; + const ext = dto.fileName.includes('.') ? dto.fileName.split('.').pop() : 'bin'; + const key = `uploads/${dto.bizType.toLowerCase()}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`; + const cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local'; + return { + bucket, + region: process.env.OSS_REGION || 'oss-cn-hangzhou', + ossKey: key, + url: `${cdnBase}/${key}`, + mock: true, + expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + mediaType: dto.mediaType, + bizType: dto.bizType, + }; + } + + async register(dto: RegisterResourceDto) { + const resource = await this.prisma.commonResource.create({ + data: { + ownerType: dto.ownerType as ResourceOwnerType, + ownerId: BigInt(dto.ownerId), + bizType: dto.bizType as ResourceBizType, + mediaType: dto.mediaType as ResourceMediaType, + ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang', + ossKey: dto.ossKey, + url: dto.url, + fileName: dto.fileName, + sortOrder: dto.sortOrder ?? 0, + }, + }); + return serializeBigInt(resource); + } + + async list(query: ResourceListQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.CommonResourceWhereInput = { + status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'], + }; + if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals']; + if (query.ownerId) where.ownerId = BigInt(query.ownerId); + if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals']; + + const [items, total] = await Promise.all([ + this.prisma.commonResource.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.commonResource.count({ where }), + ]); + return serializeBigInt({ items, total, page, pageSize }); + } + + async detail(id: bigint) { + const resource = await this.prisma.commonResource.findUnique({ where: { id } }); + if (!resource) throw new NotFoundException('资源不存在'); + return serializeBigInt(resource); + } + + async update(id: bigint, dto: UpdateResourceDto) { + await this.detail(id); + const resource = await this.prisma.commonResource.update({ + where: { id }, + data: { + ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}), + ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}), + }, + }); + return serializeBigInt(resource); + } + + async remove(id: bigint) { + await this.detail(id); + await this.prisma.commonResource.update({ + where: { id }, + data: { status: 'DELETED' }, + }); + return { ok: true }; + } +} diff --git a/server/dukang-api/src/modules/common/third-party-log.controller.ts b/server/dukang-api/src/modules/common/third-party-log.controller.ts new file mode 100644 index 0000000..9bd635c --- /dev/null +++ b/server/dukang-api/src/modules/common/third-party-log.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { ThirdPartyLogService } from './third-party-log.service'; +import { ThirdPartyLogQueryDto } from './dto/common-query.dto'; + +@Controller('common/third-party-logs') +@UseGuards(HqAuthGuard) +export class ThirdPartyLogController { + constructor(private readonly service: ThirdPartyLogService) {} + + @Get() + list(@Query() query: ThirdPartyLogQueryDto) { + return this.service.list(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } +} diff --git a/server/dukang-api/src/modules/common/third-party-log.service.ts b/server/dukang-api/src/modules/common/third-party-log.service.ts new file mode 100644 index 0000000..ae92c60 --- /dev/null +++ b/server/dukang-api/src/modules/common/third-party-log.service.ts @@ -0,0 +1,37 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { ThirdPartyLogQueryDto } from './dto/common-query.dto'; + +@Injectable() +export class ThirdPartyLogService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: ThirdPartyLogQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.LogThirdPartyWhereInput = {}; + if (query.provider) where.provider = query.provider as Prisma.EnumThirdPartyProviderFilter['equals']; + if (query.scene) where.scene = { contains: query.scene }; + if (query.refType) where.refType = query.refType; + if (query.refId) where.refId = BigInt(query.refId); + + const [items, total] = await Promise.all([ + this.prisma.logThirdParty.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.logThirdParty.count({ where }), + ]); + return serializeBigInt({ items, total, page, pageSize }); + } + + async detail(id: bigint) { + const log = await this.prisma.logThirdParty.findUnique({ where: { id } }); + if (!log) throw new NotFoundException('日志不存在'); + return serializeBigInt(log); + } +} diff --git a/server/dukang-api/src/modules/common/ticket.controller.ts b/server/dukang-api/src/modules/common/ticket.controller.ts new file mode 100644 index 0000000..faa74a9 --- /dev/null +++ b/server/dukang-api/src/modules/common/ticket.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { TicketService } from './ticket.service'; +import { TicketListQueryDto } from './dto/common-query.dto'; +import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto'; + +@Controller('common/tickets') +@UseGuards(JwtAuthGuard) +export class TicketController { + constructor(private readonly service: TicketService) {} + + @Post() + create(@Body() dto: CreateTicketDto) { + return this.service.create(dto); + } + + @Get() + list(@Query() query: TicketListQueryDto) { + return this.service.list(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } + + @Put(':id/status') + updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) { + return this.service.updateStatus(BigInt(id), dto); + } + + @Put(':id/assign') + @UseGuards(HqAuthGuard) + assign(@Param('id') id: string, @Body() dto: AssignTicketDto) { + return this.service.assign(BigInt(id), dto); + } +} diff --git a/server/dukang-api/src/modules/common/ticket.service.ts b/server/dukang-api/src/modules/common/ticket.service.ts new file mode 100644 index 0000000..603d7cb --- /dev/null +++ b/server/dukang-api/src/modules/common/ticket.service.ts @@ -0,0 +1,83 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { ActorType, TicketType } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { TicketListQueryDto } from './dto/common-query.dto'; +import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto'; + +function generateTicketNo() { + return `TK${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; +} + +@Injectable() +export class TicketService { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateTicketDto) { + const ticket = await this.prisma.commonTicket.create({ + data: { + ticketNo: generateTicketNo(), + ticketType: dto.ticketType as TicketType, + refType: dto.refType, + refId: BigInt(dto.refId), + remark: dto.remark, + param1: dto.param1, + param1Desc: dto.param1Desc, + }, + }); + return serializeBigInt(ticket); + } + + async list(query: TicketListQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.CommonTicketWhereInput = {}; + if (query.ticketType) where.ticketType = query.ticketType as Prisma.EnumTicketTypeFilter['equals']; + if (query.status) where.status = query.status; + if (query.refType) where.refType = query.refType; + if (query.refId) where.refId = BigInt(query.refId); + + const [items, total] = await Promise.all([ + this.prisma.commonTicket.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.commonTicket.count({ where }), + ]); + return serializeBigInt({ items, total, page, pageSize }); + } + + async detail(id: bigint) { + const ticket = await this.prisma.commonTicket.findUnique({ where: { id } }); + if (!ticket) throw new NotFoundException('工单不存在'); + return serializeBigInt(ticket); + } + + async updateStatus(id: bigint, dto: UpdateTicketStatusDto) { + await this.detail(id); + const ticket = await this.prisma.commonTicket.update({ + where: { id }, + data: { + status: dto.status, + remark: dto.remark, + completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(dto.status) ? new Date() : undefined, + }, + }); + return serializeBigInt(ticket); + } + + async assign(id: bigint, dto: AssignTicketDto) { + await this.detail(id); + const ticket = await this.prisma.commonTicket.update({ + where: { id }, + data: { + operatorType: dto.operatorType as ActorType, + operatorId: BigInt(dto.operatorId), + }, + }); + return serializeBigInt(ticket); + } +} diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index dd63efa..c5a099f 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -27,11 +27,13 @@ type UserRow = Pick< | 'phone' | 'phoneVerifiedAt' | 'nickname' - | 'avatarUrl' + | 'avatarResourceId' | 'wxOpenId' | 'mergedIntoUserId' | 'status' ->; +> & { + avatar?: { url: string } | null; +}; @Injectable() export class AuthService { @@ -57,6 +59,7 @@ export class AuthService { status: 1, mergedIntoUserId: null, }, + include: { avatar: true }, }); } @@ -67,13 +70,14 @@ export class AuthService { userNo: generateUserNo(), deviceKey: resolvedDeviceKey, nickname: '访客', - cityPref: { + cityPreference: { create: { selectedCityCode: '410100', selectedDistrict: '郑州市', }, }, }, + include: { avatar: true }, }); } @@ -96,7 +100,10 @@ export class AuthService { async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN); - let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone } }); + let user: UserRow | null = await this.prisma.user.findUnique({ + where: { phone }, + include: { avatar: true }, + }); if (!user) { if (guestId) { @@ -110,6 +117,7 @@ export class AuthService { phoneVerifiedAt: new Date(), nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname, }, + include: { avatar: true }, }); } } catch { @@ -123,13 +131,14 @@ export class AuthService { phoneVerifiedAt: new Date(), userNo: generateUserNo(), nickname: `用户${phone.slice(-4)}`, - cityPref: { + cityPreference: { create: { selectedCityCode: '410100', selectedDistrict: '郑州市', }, }, }, + include: { avatar: true }, }); } } else { @@ -137,6 +146,7 @@ export class AuthService { user = await this.prisma.user.update({ where: { id: user.id }, data: { phoneVerifiedAt: new Date() }, + include: { avatar: true }, }); } if (guestId && guestId !== user.id) { @@ -173,6 +183,7 @@ export class AuthService { phoneVerifiedAt: new Date(), nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname, }, + include: { avatar: true }, }); } else { await this.assertActiveUser(existing.id); @@ -293,9 +304,12 @@ export class AuthService { await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); - await tx.benefitLedger.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); + await tx.commonEvent.updateMany({ + where: { actorType: 'USER', actorId: guestId }, + data: { actorId: primaryId }, + }); await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); - await tx.eventLog.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); + await tx.logUserAnalytics.updateMany({ where: { userId: guestId }, data: { userId: primaryId } }); const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } }); const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } }); @@ -343,7 +357,10 @@ export class AuthService { } private async assertActiveUser(userId: bigint): Promise { - const user = await this.prisma.user.findUnique({ where: { id: userId } }); + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { avatar: true }, + }); if (!user) throw new NotFoundException('用户不存在'); if (user.mergedIntoUserId) { throw new UnauthorizedException('账号已合并,请重新进入'); @@ -366,7 +383,7 @@ export class AuthService { phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null, phoneVerified: !!user.phoneVerifiedAt, nickname: user.nickname, - avatarUrl: user.avatarUrl, + avatarUrl: user.avatar?.url ?? null, hasWechat: !!user.wxOpenId, }; } diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts index 95a1a79..4a7cf80 100644 --- a/server/dukang-api/src/modules/ops/admin-benefit.service.ts +++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts @@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers'; +import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat'; import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto'; @Injectable() @@ -38,12 +40,16 @@ export class AdminBenefitService { include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, order: { select: { id: true, orderNo: true, status: true, payAmount: true } }, - ledgers: { orderBy: { createdAt: 'desc' }, take: 20 }, redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } }, }, }); if (!coupon) throw new NotFoundException('权益券不存在'); - return serializeBigInt(coupon); + const ledgers = await this.prisma.commonEvent.findMany({ + where: benefitLedgerWhere(undefined, id), + orderBy: { createdAt: 'desc' }, + take: 20, + }); + return serializeBigInt({ ...coupon, ledgers }); } async voidCoupon(id: bigint) { @@ -57,8 +63,8 @@ export class AdminBenefitService { data: { status: 'VOID', balance: 0 }, }); if (Number(coupon.balance) > 0) { - await tx.benefitLedger.create({ - data: { + await tx.commonEvent.create({ + data: buildBenefitLedgerEvent({ userId: coupon.userId, couponId: coupon.id, type: 'ADJUST', @@ -66,7 +72,7 @@ export class AdminBenefitService { balanceAfter: 0, refType: 'ADMIN_VOID', remark: 'HQ 手动作废', - }, + }), }); } return row; @@ -77,24 +83,47 @@ export class AdminBenefitService { async listLedgers(query: AdminBenefitLedgersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.BenefitLedgerWhereInput = {}; - if (query.userId) where.userId = BigInt(query.userId); - if (query.couponId) where.couponId = BigInt(query.couponId); - if (query.type) where.type = query.type as Prisma.EnumBenefitLedgerTypeFilter['equals']; + const where: Prisma.CommonEventWhereInput = { + eventType: 'BENEFIT_LEDGER', + ...(query.userId ? { actorType: 'USER', actorId: BigInt(query.userId) } : {}), + ...(query.couponId ? { param2: BigInt(query.couponId).toString() } : {}), + ...(query.type ? { param1: query.type } : {}), + }; const [items, total] = await Promise.all([ - this.prisma.benefitLedger.findMany({ + this.prisma.commonEvent.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, - include: { - user: { select: { id: true, userNo: true } }, - coupon: { select: { id: true, couponNo: true } }, - }, }), - this.prisma.benefitLedger.count({ where }), + this.prisma.commonEvent.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + + const userIds = [...new Set(items.map((i) => i.actorId).filter(Boolean))] as bigint[]; + const couponIds = [...new Set(items.map((i) => i.param2).filter(Boolean))].map((id) => BigInt(id!)); + const [users, coupons] = await Promise.all([ + userIds.length + ? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, userNo: true } }) + : Promise.resolve([] as { id: bigint; userNo: string | null }[]), + couponIds.length + ? this.prisma.benefitCoupon.findMany({ where: { id: { in: couponIds } }, select: { id: true, couponNo: true } }) + : Promise.resolve([] as { id: bigint; couponNo: string }[]), + ]); + const userMap = new Map(users.map((u) => [u.id.toString(), u] as const)); + const couponMap = new Map(coupons.map((c) => [c.id.toString(), c] as const)); + + return serializeBigInt({ + items: items.map((e) => + mapBenefitLedgerCompat( + e, + e.actorId ? userMap.get(e.actorId.toString()) : null, + e.param2 ? couponMap.get(e.param2) : null, + ), + ), + total, + page, + pageSize, + }); } } diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts index 92c3f4d..681ed40 100644 --- a/server/dukang-api/src/modules/ops/admin-cities.service.ts +++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts @@ -12,14 +12,14 @@ export class AdminCitiesService { async list(query: AdminCitiesQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.CityWhereInput = {}; + const where: Prisma.CommonCityWhereInput = {}; if (query.name) where.name = { contains: query.name }; if (query.code) where.code = { contains: query.code }; if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals']; if (query.partnerId) where.partnerId = BigInt(query.partnerId); const [items, total] = await Promise.all([ - this.prisma.city.findMany({ + this.prisma.commonCity.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, @@ -29,7 +29,7 @@ export class AdminCitiesService { _count: { select: { stores: true, orders: true } }, }, }), - this.prisma.city.count({ where }), + this.prisma.commonCity.count({ where }), ]); return serializeBigInt({ items: items.map((c) => ({ @@ -45,7 +45,7 @@ export class AdminCitiesService { } async detail(id: bigint) { - const city = await this.prisma.city.findUnique({ + const city = await this.prisma.commonCity.findUnique({ where: { id }, include: { partner: true, @@ -58,9 +58,9 @@ export class AdminCitiesService { } async create(dto: CreateCityDto) { - const exists = await this.prisma.city.findUnique({ where: { code: dto.code } }); + const exists = await this.prisma.commonCity.findUnique({ where: { code: dto.code } }); if (exists) throw new BadRequestException('城市编码已存在'); - const city = await this.prisma.city.create({ + const city = await this.prisma.commonCity.create({ data: { code: dto.code, name: dto.name, @@ -79,7 +79,7 @@ export class AdminCitiesService { } async update(id: bigint, dto: UpdateCityDto) { - const city = await this.prisma.city.update({ + const city = await this.prisma.commonCity.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index ad63bbd..b207dfc 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { orderStatusLogWhere } from '../../common/event/event.helpers'; +import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; @Injectable() @@ -54,17 +56,24 @@ export class AdminOrdersService { phoneVerifiedAt: true, }, }, - items: true, delivery: true, - payment: true, - statusLogs: { orderBy: { createdAt: 'asc' } }, - benefitCoupons: { + benefitCoupon: { select: { id: true, couponNo: true, balance: true, status: true }, }, city: { select: { id: true, name: true, code: true } }, + product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, + imageResource: { select: { id: true, url: true } }, }, }); if (!order) throw new NotFoundException('订单不存在'); - return serializeBigInt(order); + const statusLogs = await this.prisma.commonEvent.findMany({ + where: orderStatusLogWhere(id), + orderBy: { createdAt: 'asc' }, + }); + return serializeBigInt(mapOrderCompat({ + ...order, + statusLogs: mapStatusLogCompat(statusLogs), + benefitCoupons: order.benefitCoupon ? [order.benefitCoupon] : [], + })); } } diff --git a/server/dukang-api/src/modules/ops/admin-products.controller.ts b/server/dukang-api/src/modules/ops/admin-products.controller.ts new file mode 100644 index 0000000..efbea21 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-products.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { AdminProductsService } from './admin-products.service'; +import { AdminProductsQueryDto } from './dto/admin-query.dto'; +import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; + +@Controller('admin/products') +@UseGuards(HqAuthGuard) +export class AdminProductsController { + constructor(private readonly service: AdminProductsService) {} + + @Get() + list(@Query() query: AdminProductsQueryDto) { + return this.service.list(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.service.detail(BigInt(id)); + } + + @Post() + create(@Body() dto: CreateProductDto) { + return this.service.create(dto); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateProductDto) { + return this.service.update(BigInt(id), dto); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts new file mode 100644 index 0000000..f8e730e --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-products.service.ts @@ -0,0 +1,135 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { AdminProductsQueryDto } from './dto/admin-query.dto'; +import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto'; + +@Injectable() +export class AdminProductsService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: AdminProductsQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.CommonProductItemWhereInput = {}; + if (query.name) where.name = { contains: query.name }; + if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals']; + if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals']; + + const [items, total] = await Promise.all([ + this.prisma.commonProductItem.findMany({ + where, + orderBy: { sortOrder: 'asc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { coverResource: true }, + }), + this.prisma.commonProductItem.count({ where }), + ]); + return serializeBigInt({ + items: items.map((p) => ({ + ...p, + mainImageUrl: p.coverResource?.url ?? null, + })), + total, + page, + pageSize, + }); + } + + async detail(id: bigint) { + const product = await this.prisma.commonProductItem.findUnique({ + where: { id }, + include: { coverResource: true }, + }); + if (!product) throw new NotFoundException('商品不存在'); + return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null }); + } + + async create(dto: CreateProductDto) { + const exists = await this.prisma.commonProductItem.findFirst({ + where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] }, + }); + if (exists) throw new BadRequestException('SKU 或 69 码已存在'); + + const product = await this.prisma.commonProductItem.create({ + data: { + skuCode: dto.skuCode, + barcode69: dto.barcode69, + name: dto.name, + subtitle: dto.subtitle, + aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG', + spec: dto.spec, + price: dto.price, + benefitAmount: dto.benefitAmount ?? dto.price, + status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE', + sortOrder: dto.sortOrder ?? 0, + }, + }); + + if (dto.coverUrl) { + const cover = await this.prisma.commonResource.create({ + data: { + ownerType: 'PRODUCT', + ownerId: product.id, + bizType: 'COVER', + mediaType: 'IMAGE', + ossBucket: 'legacy', + ossKey: dto.coverUrl, + url: dto.coverUrl, + }, + }); + await this.prisma.commonProductItem.update({ + where: { id: product.id }, + data: { coverResourceId: cover.id }, + }); + } + + return this.detail(product.id); + } + + async update(id: bigint, dto: UpdateProductDto) { + await this.detail(id); + await this.prisma.commonProductItem.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.subtitle !== undefined ? { subtitle: dto.subtitle } : {}), + ...(dto.spec !== undefined ? { spec: dto.spec } : {}), + ...(dto.price !== undefined ? { price: dto.price } : {}), + ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}), + ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + }, + }); + + if (dto.coverUrl) { + const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } }); + if (product.coverResourceId) { + await this.prisma.commonResource.update({ + where: { id: product.coverResourceId }, + data: { url: dto.coverUrl, ossKey: dto.coverUrl }, + }); + } else { + const cover = await this.prisma.commonResource.create({ + data: { + ownerType: 'PRODUCT', + ownerId: id, + bizType: 'COVER', + mediaType: 'IMAGE', + ossBucket: 'legacy', + ossKey: dto.coverUrl, + url: dto.coverUrl, + }, + }); + await this.prisma.commonProductItem.update({ + where: { id }, + data: { coverResourceId: cover.id }, + }); + } + } + + return this.detail(id); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index b1ba17a..57280b8 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { DeliveryProvider } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -42,7 +43,6 @@ export class AdminRedeemService { store: { include: { partner: { select: { id: true, companyName: true } } } }, coupon: true, payout: true, - commissions: true, }, }); if (!record) throw new NotFoundException('核销记录不存在'); @@ -58,7 +58,7 @@ export class AdminDeliveriesService { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.OrderDeliveryWhereInput = {}; - if (query.provider) where.provider = query.provider; + if (query.provider) where.provider = query.provider as DeliveryProvider; if (query.trackingNo) where.trackingNo = { contains: query.trackingNo }; if (query.orderNo) { where.order = { orderNo: { contains: query.orderNo } }; @@ -79,6 +79,8 @@ export class AdminDeliveriesService { receiverName: true, receiverPhone: true, deliveryType: true, + productName: true, + quantity: true, }, }, }, @@ -95,7 +97,7 @@ export class AdminDeliveriesService { order: { include: { user: { select: { id: true, userNo: true, phone: true } }, - items: true, + imageResource: { select: { url: true } }, }, }, }, @@ -108,7 +110,7 @@ export class AdminDeliveriesService { const delivery = await this.prisma.orderDelivery.update({ where: { id }, data: { - ...(dto.provider !== undefined ? { provider: dto.provider } : {}), + ...(dto.provider !== undefined ? { provider: dto.provider as DeliveryProvider } : {}), ...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}), ...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}), }, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index d44a039..3e32e65 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { mapStoreCompat } from '../../common/compat/v31-compat'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import type { CreateStoreAccountDto, @@ -37,11 +38,17 @@ export class AdminStoresService { cityRef: { select: { id: true, name: true, code: true } }, partner: { select: { id: true, companyName: true } }, account: { select: { id: true, phone: true, name: true, status: true } }, + coverResource: { select: { id: true, url: true } }, }, }), this.prisma.store.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + return serializeBigInt({ + items: items.map((s) => mapStoreCompat(s)), + total, + page, + pageSize, + }); } async detailStore(id: bigint) { @@ -52,18 +59,30 @@ export class AdminStoresService { partner: true, category: true, account: true, - media: { orderBy: { sortOrder: 'asc' } }, - audits: { orderBy: { submittedAt: 'desc' }, take: 5 }, + coverResource: true, _count: { select: { redeemRecords: true, ratings: true } }, }, }); if (!store) throw new NotFoundException('门店不存在'); - return serializeBigInt({ + const [media, audits] = await Promise.all([ + this.prisma.commonResource.findMany({ + where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' }, + orderBy: { sortOrder: 'asc' }, + }), + this.prisma.commonEvent.findMany({ + where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id }, + orderBy: { createdAt: 'desc' }, + take: 5, + }), + ]); + return serializeBigInt(mapStoreCompat({ ...store, + media, + audits, redeemCount: store._count.redeemRecords, ratingCount: store._count.ratings, _count: undefined, - }); + })); } async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) { @@ -81,18 +100,41 @@ export class AdminStoresService { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.phone !== undefined ? { phone: dto.phone } : {}), ...(dto.intro !== undefined ? { intro: dto.intro } : {}), - ...(dto.coverUrl !== undefined ? { coverUrl: dto.coverUrl } : {}), ...(dto.address !== undefined ? { address: dto.address } : {}), ...(dto.district !== undefined ? { district: dto.district } : {}), }, }); - return serializeBigInt(store); + + if (dto.coverUrl) { + const current = await this.prisma.store.findUniqueOrThrow({ where: { id } }); + if (current.coverResourceId) { + await this.prisma.commonResource.update({ + where: { id: current.coverResourceId }, + data: { url: dto.coverUrl, ossKey: dto.coverUrl }, + }); + } else { + const cover = await this.prisma.commonResource.create({ + data: { + ownerType: 'STORE', + ownerId: id, + bizType: 'COVER', + mediaType: 'IMAGE', + ossBucket: 'legacy', + ossKey: dto.coverUrl, + url: dto.coverUrl, + }, + }); + await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } }); + } + } + + return this.detailStore(id); } async createStore(dto: CreateStoreDto) { const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } }); if (!partner) throw new BadRequestException('开城合伙人不存在'); - const city = await this.prisma.city.findUnique({ where: { id: BigInt(dto.cityId) } }); + const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } }); if (!city) throw new BadRequestException('开城城市不存在'); const store = await this.prisma.store.create({ @@ -107,7 +149,6 @@ export class AdminStoresService { district: dto.district ?? '', address: dto.address, intro: dto.intro ?? null, - coverUrl: dto.coverUrl ?? null, status: 'OPEN', }, }); @@ -139,19 +180,21 @@ export class AdminStoresService { async listStoreMedia(query: AdminStoreMediaQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.StoreMediaWhereInput = {}; - if (query.storeId) where.storeId = BigInt(query.storeId); - if (query.mediaType) where.mediaType = query.mediaType; + const where: Prisma.CommonResourceWhereInput = { + ownerType: 'STORE', + status: 'ACTIVE', + }; + if (query.storeId) where.ownerId = BigInt(query.storeId); + if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals']; const [items, total] = await Promise.all([ - this.prisma.storeMedia.findMany({ + this.prisma.commonResource.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, - include: { store: { select: { id: true, name: true } } }, }), - this.prisma.storeMedia.count({ where }), + this.prisma.commonResource.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } @@ -159,10 +202,14 @@ export class AdminStoresService { async createStoreMedia(dto: CreateStoreMediaDto) { const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } }); if (!store) throw new BadRequestException('门店不存在'); - const media = await this.prisma.storeMedia.create({ + const media = await this.prisma.commonResource.create({ data: { - storeId: store.id, - mediaType: dto.mediaType, + ownerType: 'STORE', + ownerId: store.id, + bizType: 'ENV', + mediaType: dto.mediaType as 'IMAGE' | 'VIDEO', + ossBucket: 'legacy', + ossKey: dto.url, url: dto.url, sortOrder: dto.sortOrder ?? 0, }, @@ -171,11 +218,11 @@ export class AdminStoresService { } async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) { - const media = await this.prisma.storeMedia.update({ + const media = await this.prisma.commonResource.update({ where: { id }, data: { - ...(dto.url !== undefined ? { url: dto.url } : {}), - ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType } : {}), + ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}), + ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), }, }); @@ -183,7 +230,10 @@ export class AdminStoresService { } async deleteStoreMedia(id: bigint) { - await this.prisma.storeMedia.delete({ where: { id } }); + await this.prisma.commonResource.update({ + where: { id }, + data: { status: 'DELETED' }, + }); return { ok: true }; } diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 4858020..24699f0 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -59,7 +59,7 @@ export class AdminUsersService { const user = await this.prisma.user.findUnique({ where: { id }, include: { - cityPref: true, + cityPreference: true, mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } }, orders: { orderBy: { createdAt: 'desc' }, diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 6b2ef85..256facc 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -311,3 +311,81 @@ export class UpdateHqAccountDto { @IsIn(['ACTIVE', 'DISABLED']) status?: string; } + +export class CreateProductDto { + @IsString() + @IsNotEmpty() + skuCode: string; + + @IsString() + @IsNotEmpty() + barcode69: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsString() + subtitle?: string; + + @IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG']) + aromaType: string; + + @IsString() + @IsNotEmpty() + spec: string; + + @IsNumber() + price: number; + + @IsOptional() + @IsNumber() + benefitAmount?: number; + + @IsOptional() + @IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE']) + status?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsString() + coverUrl?: string; +} + +export class UpdateProductDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + subtitle?: string; + + @IsOptional() + @IsString() + spec?: string; + + @IsOptional() + @IsNumber() + price?: number; + + @IsOptional() + @IsNumber() + benefitAmount?: number; + + @IsOptional() + @IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE']) + status?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsString() + coverUrl?: string; +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 2acfb23..e5d29c1 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -213,6 +213,20 @@ export class AdminCitiesQueryDto extends PaginationQueryDto { partnerId?: string; } +export class AdminProductsQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + aromaType?: string; +} + export class AdminStoreMediaQueryDto extends PaginationQueryDto { @IsOptional() @IsString() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 4ea8a99..e7135ba 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -18,6 +18,8 @@ import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service'; import { AdminHqAccountsController } from './admin-hq-accounts.controller'; import { AdminHqAccountsService } from './admin-hq-accounts.service'; +import { AdminProductsController } from './admin-products.controller'; +import { AdminProductsService } from './admin-products.service'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; @Module({ @@ -37,6 +39,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; AdminRedeemRecordsController, AdminDeliveriesController, AdminHqAccountsController, + AdminProductsController, ], providers: [ AdminDashboardService, @@ -49,6 +52,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; AdminRedeemService, AdminDeliveriesService, AdminHqAccountsService, + AdminProductsService, SuperAdminGuard, ], }) diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index d140ee9..0a8e088 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -15,6 +15,7 @@ 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'; +import { buildBenefitLedgerEvent } from '../../common/event/event.helpers'; @Injectable() export class RedeemService { @@ -57,17 +58,6 @@ export class RedeemService { 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}`, { @@ -130,7 +120,7 @@ export class RedeemService { } const amount = Number(cached.amount); - const cityRule = await this.prisma.cityCommissionRule.findFirst({ + const cityRule = await this.prisma.commonCityCommissionRule.findFirst({ where: { city: { stores: { some: { id: account.storeId } } } }, }); const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6; @@ -154,8 +144,8 @@ export class RedeemService { if (updated.count === 0) throw new BadRequestException('核销失败,请重试'); const newBalance = Number(coupon.balance) - allocAmount; - await tx.benefitLedger.create({ - data: { + await tx.commonEvent.create({ + data: buildBenefitLedgerEvent({ userId: coupon.userId, couponId: coupon.id, type: 'REDEEM', @@ -163,7 +153,7 @@ export class RedeemService { balanceAfter: newBalance, refType: 'STORE', refId: account.storeId, - }, + }), }); } @@ -178,11 +168,6 @@ export class RedeemService { }, }); - await tx.redeemToken.updateMany({ - where: { token: body.token }, - data: { status: 'USED', usedAt: new Date(), storeId: account.storeId }, - }); - return redeemRecord; }); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 7636711..46ace49 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -6,6 +6,7 @@ import { import { loadAppConfig } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { mapStoreCompat } from '../../common/compat/v31-compat'; @Injectable() export class StoreService { @@ -16,39 +17,43 @@ export class StoreService { async listOpenStores(cityCode?: string) { const where: Record = { status: 'OPEN' }; if (cityCode) { - const city = await this.prisma.city.findFirst({ where: { code: cityCode } }); + const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } }); if (city) where.cityId = city.id; } const stores = await this.prisma.store.findMany({ where: where as never, - include: { category: true }, + include: { category: true, coverResource: true }, orderBy: { createdAt: 'desc' }, }); - return serializeBigInt(stores); + return serializeBigInt(stores.map(mapStoreCompat)); } async getStore(id: bigint) { const store = await this.prisma.store.findFirst({ where: { id, status: 'OPEN' }, - include: { category: true, media: true }, + include: { category: true, coverResource: true }, }); if (!store) throw new NotFoundException('门店不存在'); - return serializeBigInt(store); + const media = await this.prisma.commonResource.findMany({ + where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, + orderBy: { sortOrder: 'asc' }, + }); + return serializeBigInt(mapStoreCompat({ ...store, media })); } 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 } }, + include: { category: true, coverResource: true }, orderBy: { createdAt: 'desc' }, }); - return serializeBigInt(stores); + return serializeBigInt(stores.map(mapStoreCompat)); } async createStore(partnerAccountId: bigint, body: Record) { const account = await this.getPartnerAccount(partnerAccountId); - const city = await this.prisma.city.findFirst({ where: { partnerId: account.partnerId } }); + const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } }); if (!city) throw new BadRequestException('合伙人未绑定开城'); const store = await this.prisma.store.create({ @@ -63,7 +68,7 @@ export class StoreService { district: String(body.district ?? ''), address: String(body.address), intro: body.intro ? String(body.intro) : null, - coverUrl: body.coverUrl ? String(body.coverUrl) : null, + coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null, bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null, bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null, bankBranch: body.bankBranch ? String(body.bankBranch) : null, @@ -73,13 +78,17 @@ export class StoreService { }, }); - const audit = await this.prisma.storeAudit.create({ + const audit = await this.prisma.commonEvent.create({ data: { - storeId: store.id, - auditType: 'NEW', + eventType: 'STORE_AUDIT', + refType: 'STORE', + refId: store.id, + actorType: 'PARTNER', + actorId: partnerAccountId, status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING', - submitData: body as never, - reviewedAt: this.config.autoApproveStore ? new Date() : null, + param1: 'NEW', + param1Desc: 'audit_type', + extraJson: body as never, }, }); @@ -97,9 +106,9 @@ export class StoreService { async getShopStore(storeAccountId: bigint) { const account = await this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, - include: { store: { include: { category: true } } }, + include: { store: { include: { category: true, coverResource: true } } }, }); - return serializeBigInt(account.store); + return serializeBigInt(mapStoreCompat(account.store)); } async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') { diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index cd178e3..7e9e618 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import type { FreightPayType } from '@prisma/client'; import { calcBenefitAmount, generateOrderNo, @@ -19,6 +20,8 @@ import { IDeliveryProvider } from '../../integrations/delivery/delivery.interfac import { IpGeoService } from '../../common/geo/ip-geo.service'; import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util'; import { extractClientIp } from '../../common/geo/client-ip.util'; +import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers'; +import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; import type { Request } from 'express'; @Injectable() @@ -32,13 +35,13 @@ export class TradeService { ) {} async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) { - const product = await this.prisma.product.findUnique({ + const product = await this.prisma.commonProductItem.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' } }); + const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL'; @@ -66,13 +69,15 @@ export class TradeService { benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null, }); + const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; + return { product: serializeBigInt(product), quantity: body.quantity, deliveryType, productAmount, freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0, - freightPayType: deliveryType === 'CROSS_CITY' ? 'COD' : null, + freightPayType, payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, city: serializeBigInt(city), @@ -95,10 +100,10 @@ export class TradeService { }); if (!address) throw new BadRequestException('请选择收货地址'); - const product = await this.prisma.product.findUniqueOrThrow({ + const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); - const city = await this.prisma.city.findFirstOrThrow({ where: { status: 'ACTIVE' } }); + const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); @@ -114,7 +119,17 @@ export class TradeService { userId, cityId: city.id, status: 'PENDING_PAY', + payStatus: 'UNPAID', deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY', + productId: product.id, + barcode69: product.barcode69, + productName: product.name, + productSpec: product.spec, + imageResourceId: product.coverResourceId, + quantity: body.quantity, + listUnitPrice: product.price, + listAmount: preview.productAmount, + productAmount: preview.productAmount, receiverName: address.receiverName, receiverPhone: address.phone, receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`, @@ -131,41 +146,21 @@ export class TradeService { gpsLatitude: location.gpsLatitude, gpsLongitude: location.gpsLongitude, gpsAddress: location.gpsAddress, - 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 }, + include: { product: true, imageResource: true }, }); - return serializeBigInt(order); + return serializeBigInt(mapOrderCompat(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') { @@ -176,28 +171,36 @@ export class TradeService { 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: { + status: 'PENDING_SHIP', + payStatus: 'PAID', + paidAt: now, + payExternalNo: externalNo, + }, + }); + await tx.logThirdParty.create({ + data: { + provider: 'WECHAT_PAY', + scene: 'ORDER_PAY', + refType: 'ORDER', + refId: order.id, + externalNo, + amount: order.payAmount, + status: 'SUCCESS', + }, + }); + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', toStatus: 'PENDING_SHIP', operator: 'MOCK_PAY', - }, + }), }); await tx.orderDelivery.create({ - data: { orderId: order.id, provider: 'MOCK' }, + data: { orderId: order.id, provider: 'MANUAL' }, }); }); @@ -216,29 +219,32 @@ export class TradeService { const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, - include: { items: true, benefitCoupons: true }, + include: { benefitCoupon: true, imageResource: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.order.count({ where }), ]); - return { list: serializeBigInt(list), total, page, pageSize }; + return { list: serializeBigInt(list.map(mapOrderCompat)), 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' } }, + benefitCoupon: true, + imageResource: true, + product: true, }, }); if (!order) throw new NotFoundException('订单不存在'); - return serializeBigInt(order); + const statusLogs = await this.prisma.commonEvent.findMany({ + where: orderStatusLogWhere(orderId), + orderBy: { createdAt: 'desc' }, + }); + return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) })); } async updateAddress(userId: bigint, orderId: bigint, body: Record) { @@ -258,14 +264,14 @@ export class TradeService { receiverAddress: String(body.receiverAddress ?? order.receiverAddress), }, }); - await this.prisma.orderStatusLog.create({ - data: { + await this.prisma.commonEvent.create({ + data: buildOrderStatusEvent({ orderId, fromStatus: order.status, toStatus: order.status, operator: 'USER', remark: '修改收货地址', - }, + }), }); return serializeBigInt(updated); } @@ -284,33 +290,37 @@ export class TradeService { const account = await this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: partnerAccountId }, }); - const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } }); + const cities = await this.prisma.commonCity.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 } } }, + include: { delivery: true, imageResource: 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 }; + return { list: serializeBigInt(list.map(mapOrderCompat)), 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 cities = await this.prisma.commonCity.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 }, + include: { delivery: true, user: true, imageResource: true }, }); if (!order) throw new NotFoundException('订单不存在'); - return serializeBigInt(order); + const statusLogs = await this.prisma.commonEvent.findMany({ + where: orderStatusLogWhere(orderId), + orderBy: { createdAt: 'desc' }, + }); + return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) })); } async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) { @@ -357,13 +367,13 @@ export class TradeService { if (Object.keys(deliveryData).length) { await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never }); } - await tx.orderStatusLog.create({ - data: { + await tx.commonEvent.create({ + data: buildOrderStatusEvent({ orderId, fromStatus: currentStatus, toStatus: targetStatus, operator, - }, + }), }); }); }