v3数据表更改
This commit is contained in:
@@ -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. 启动服务
|
||||
|
||||
@@ -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 <Navigate to="/login" replace />;
|
||||
@@ -36,6 +37,7 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
|
||||
@@ -20,6 +20,7 @@ const { Header, Sider, Content } = Layout;
|
||||
const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||
{ key: '/products', icon: <ShoppingOutlined />, label: '商品' },
|
||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||
{
|
||||
key: 'stores-group',
|
||||
|
||||
@@ -44,6 +44,18 @@ export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||
VIDEO: '视频',
|
||||
};
|
||||
|
||||
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
ON_SALE: '在售',
|
||||
OFF_SALE: '下架',
|
||||
};
|
||||
|
||||
export const AROMA_TYPE_LABELS: Record<string, string> = {
|
||||
QINGXIANG: '清香型',
|
||||
JIANGXIANG: '酱香型',
|
||||
NONGXIANG: '浓香型',
|
||||
};
|
||||
|
||||
export const LEDGER_TYPE_LABELS: Record<string, string> = {
|
||||
GRANT: '发放',
|
||||
REDEEM: '核销',
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/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<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ 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) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑商品" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input placeholder="https://..." /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建商品" open={createOpen} onCancel={() => 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}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
|
||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input placeholder="500ml | 52度" /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL"><Input placeholder="https://..." /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function formatAddress(a: Address) {
|
||||
}
|
||||
|
||||
export default function AddressListPage() {
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 已激活**) |
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<T extends OrderLike>(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<T extends { coverResource?: { url?: string } | null }>(store: T) {
|
||||
return {
|
||||
...store,
|
||||
coverUrl: store.coverResource?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapStatusLogCompat(events: CommonEvent[]) {
|
||||
return events.map((e) => ({
|
||||
fromStatus: e.param1,
|
||||
toStatus: e.param2 ?? '',
|
||||
operator: e.param3,
|
||||
remark: e.remark,
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mapBenefitLedgerCompat(
|
||||
event: CommonEvent,
|
||||
user?: { userNo?: string | null } | null,
|
||||
coupon?: { couponNo?: string } | null,
|
||||
) {
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.param1,
|
||||
amount: event.amount1 != null ? Number(event.amount1) : 0,
|
||||
balanceAfter: event.amount2 != null ? Number(event.amount2) : 0,
|
||||
remark: event.remark,
|
||||
createdAt: event.createdAt,
|
||||
userId: event.actorId,
|
||||
couponId: event.param2 ? BigInt(event.param2) : null,
|
||||
user: user ? { userNo: user.userNo } : undefined,
|
||||
coupon: coupon ? { couponNo: coupon.couponNo } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> }>,
|
||||
) {
|
||||
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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<UserRow> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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] : [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = { 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<string, unknown>) {
|
||||
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') {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user