发布商品,商品图片使用oss服务器地址

This commit is contained in:
2026-07-06 08:53:03 +08:00
parent a0466f023e
commit 5ba69eb935
60 changed files with 2776 additions and 157 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): enforce 500 yuan cap
fix(redeem): validate redeem amount against balance
chore(shared-types): add OrderStatus enum
```
+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main, master, develop]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm db:generate
- run: pnpm test
- run: pnpm lint
+10 -7
View File
@@ -8,8 +8,9 @@
| 阶段 | 手册 | 说明 |
|------|------|------|
| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API |
| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源 |
| **当前 V3 交付** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 业务闭环交付验收标准 |
| **preV1 历史** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | Mock 联调裁剪(历史参考 |
| **V2 蓝图** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 完整产品蓝图(与 V3 冲突时 V3 优先) |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
@@ -33,6 +34,7 @@ pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
@@ -43,11 +45,12 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
## 文档优先级(冲突时)
1. `杜康好客-V2编码手册.md` §二 — 业务规则
2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag
3. [`conventions.md`](./conventions.md) — 协作与模块边界
4. V2 手册 §四 §五 §六 — 架构 / DB / API
5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
1. `杜康好客-v3编码手册.md`V3 交付业务规则与验收
2. `杜康好客-V2编码手册.md` §二 — 完整蓝图(与 V3 冲突时 V3 优先
3. `杜康好客-preV1编码手册.md` — preV1 裁剪(历史参考)
4. [`conventions.md`](./conventions.md) — 协作与模块边界
5. V2 手册 §四 §五 §六 — 架构 / DB / API
6. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
## 团队与 OWNER 边界(2 人)
+6 -3
View File
@@ -1,6 +1,6 @@
# 杜康好客 preV1
# 杜康好客 V3 交付版
端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`preV1 裁剪见 `杜康好客-preV1编码手册.md`
端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考
## 快速启动
@@ -53,9 +53,12 @@ pnpm dev:partner # http://localhost:5175
## 主链路冒烟
```bash
node scripts/smoke-prev1.mjs
node scripts/smoke-v3.mjs # V3 交付验收
node scripts/smoke-prev1.mjs # preV1 历史回归
```
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
## 目录结构
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5Vite + React
+52
View File
@@ -0,0 +1,52 @@
# V3 验收清单
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
> 自动化:`node scripts/smoke-v3.mjs`Mock 环境)
> 历史回归:`node scripts/smoke-prev1.mjs`preV1 单链路)
## 必过主链路
| # | 验收项 | Owner | API / 页面 | 自动化 |
|---|--------|-------|------------|--------|
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
| 7 | 直接核销(无 couponId | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
## 必过后台链路
| # | 验收项 | 页面 |
|---|--------|------|
| 1 | WebAdmin 登录 | admin-web `/login` |
| 2 | 商品 CRUD | `/products` |
| 3 | 审核门店 | `/stores` |
| 4 | 订单与配送 | `/orders` · `/deliveries` |
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
| 6 | 退款/补发工单 | `/tickets` |
| 7 | 第三方日志 | `/third-party-logs` |
| 8 | 财务导出 | `/partner-bills` export |
## 环境矩阵
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|------|----------|----------|-------------------|------|
| 本地开发 | true | true | true | 固定验证码 123456 |
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
| 生产 | false | false | false | 真实第三方 |
## 合伙人端交接(刘京尧)
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
刘京尧负责:`apps/h5-partner` 页面接入与验收。
+8
View File
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ResourcesPage from './pages/ResourcesPage';
import StorePayoutsPage from './pages/StorePayoutsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import TicketsPage from './pages/TicketsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -49,6 +53,10 @@ export default function App() {
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/store-payouts" element={<StorePayoutsPage />} />
<Route path="/partner-bills" element={<PartnerBillsPage />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
@@ -52,8 +52,12 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/benefit/coupons', label: '权益券' },
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/store-payouts', label: '门店打款' },
],
},
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
billNo: string;
orderCommission: number;
redeemCommission: number;
totalAmount: number;
status: string;
periodStart: string;
periodEnd: string;
partner?: { companyName: string };
};
export default function PartnerBillsPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
return qs;
},
[filters],
);
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({
partnerId: values.partnerId,
year: values.month.year(),
month: values.month.month() + 1,
}),
});
message.success('账单已生成');
setGenOpen(false);
reload();
}
async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认');
reload();
}
async function markPaid(id: string) {
await request(`/admin/partner-bills/${id}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
message.success('已标记打款');
reload();
}
async function exportCsv() {
const result = await request<{ csv: string }>('/admin/partner-bills/export');
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'partner-bills.csv';
a.click();
URL.revokeObjectURL(url);
}
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180 },
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
{
title: '操作', width: 180,
render: (_, row) => (
<>
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}></Button>}
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}></Button>}
</>
),
},
];
return (
<div>
<Typography.Title level={4}>T+30</Typography.Title>
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<Button type="primary" onClick={() => setGenOpen(true)}></Button>
<Button onClick={exportCsv}> CSV</Button>
</div>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'DRAFT', label: '草稿' },
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'PAID', label: '已打款' },
]} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" 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); } }} />
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form form={genForm} layout="vertical" onFinish={generateBill}>
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
<Button type="primary" htmlType="submit" block></Button>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
redeemAmount: number;
payoutAmount: number;
status: string;
expectedPayAt: string;
paidAt?: string;
store?: { name: string; cityName: string };
redeemRecord?: { redeemNo: string };
};
export default function StorePayoutsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-payouts',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
async function confirmPayout(id: string) {
await request(`/admin/store-payouts/${id}/confirm`, {
method: 'POST',
body: JSON.stringify({ remark: '财务确认打款' }),
});
message.success('已确认打款');
reload();
}
const columns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 100 },
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
{
title: '操作', width: 140,
render: (_, row) => (
<>
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}></Button>
{row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => confirmPayout(row.id)}></Button>
)}
</>
),
},
];
return (
<div>
<Typography.Title level={4}>T+1</Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'PENDING', label: '待打款' },
{ value: 'PAID', label: '已打款' },
]} />
</Form.Item>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
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)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="打款额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
provider: string;
scene: string;
refType: string;
refId: string;
externalNo?: string;
status: string;
createdAt: string;
};
export default function ThirdPartyLogsPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/common/third-party-logs',
() => {
const qs = new URLSearchParams();
if (filters.provider) qs.set('provider', filters.provider);
if (filters.refId) qs.set('refId', filters.refId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: 'Provider', dataIndex: 'provider', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 120 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '外部单号', dataIndex: 'externalNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/common/third-party-logs/${row.id}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="provider" label="Provider"><Input allowClear placeholder="WECHAT_PAY" /></Form.Item>
<Form.Item name="refId" label="关联ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
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)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
ticketNo: string;
ticketType: string;
status: string;
refType: string;
refId: string;
remark?: string;
createdAt: string;
};
export default function TicketsPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/tickets',
() => {
const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
async function approve(id: string) {
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
message.success('已审批通过');
reload();
setDrawerOpen(false);
}
async function reject(id: string) {
await request(`/admin/tickets/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
setDrawerOpen(false);
}
const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ title: '类型', dataIndex: 'ticketType', width: 100 },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'REFUND', label: '退款' },
{ value: 'RESHIPMENT', label: '补发' },
{ value: 'ALERT', label: '异常' },
]} />
</Form.Item>
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button>
<Button danger onClick={() => reject(String(detail.id))}></Button>
</>
) : null}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+52 -11
View File
@@ -6,6 +6,14 @@ function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type Preview = {
amount: number;
expireInSeconds: number;
user?: { userNo?: string; phone?: string; nickname?: string };
redeemType?: string;
boundStoreId?: string | null;
};
export default function RedeemConfirmPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -13,11 +21,15 @@ export default function RedeemConfirmPage() {
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [storeName, setStoreName] = useState('');
const [previewAmount, setPreviewAmount] = useState(100);
const [preview, setPreview] = useState<Preview | null>(null);
const [storeClosed, setStoreClosed] = useState(false);
useEffect(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => setStoreName(String(s.name || '当前门店')))
.then((s) => {
setStoreName(String(s.name || '当前门店'));
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
})
.catch(() => setStoreName('当前门店'));
}, []);
@@ -26,7 +38,27 @@ export default function RedeemConfirmPage() {
if (scanned) setToken(scanned);
}, [searchParams]);
useEffect(() => {
if (!token.trim()) {
setPreview(null);
return;
}
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then(setPreview)
.catch((e) => {
setPreview(null);
setMsg(e instanceof Error ? e.message : '无法预览核销码');
});
}, [token]);
async function confirm() {
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
}
if (!token.trim()) {
setMsg('请在开发者选项中输入核销码');
return;
@@ -47,6 +79,9 @@ export default function RedeemConfirmPage() {
}
}
const previewAmount = preview?.amount ?? 0;
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认';
return (
<div className="shop-redeem-page">
<header className="shop-redeem-header">
@@ -57,6 +92,9 @@ export default function RedeemConfirmPage() {
</header>
<main className="shop-redeem-main">
{storeClosed && (
<p className="shop-redeem-error" style={{ marginBottom: 12 }}></p>
)}
<section className="shop-redeem-card">
<div className="shop-redeem-banner">
<div className="shop-redeem-banner-icon">
@@ -75,7 +113,7 @@ export default function RedeemConfirmPage() {
<span></span>
</div>
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
{userLabel}
</span>
</div>
@@ -91,7 +129,7 @@ export default function RedeemConfirmPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
confirmation_number
</span>
· {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
</span>
</div>
@@ -102,8 +140,14 @@ export default function RedeemConfirmPage() {
</div>
<div className="shop-redeem-detail-row">
<span></span>
<span></span>
<span>{preview ? `${preview.expireInSeconds}` : '—'}</span>
</div>
{preview?.boundStoreId && (
<div className="shop-redeem-detail-row">
<span></span>
<span></span>
</div>
)}
</div>
{msg && <p className="shop-redeem-error">{msg}</p>}
@@ -111,13 +155,13 @@ export default function RedeemConfirmPage() {
<button
type="button"
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
disabled={loading}
disabled={loading || storeClosed || !preview}
onClick={confirm}
>
<span className="material-symbols-outlined shop-fill-icon">
{loading ? 'sync' : 'check_circle'}
</span>
<span>{loading ? '正在核销...' : `确认核销 ¥${formatAmount(previewAmount)}`}</span>
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'}</span>
</button>
<p className="shop-redeem-hint"></p>
@@ -127,10 +171,7 @@ export default function RedeemConfirmPage() {
<div className="shop-redeem-dev-body">
<input
value={token}
onChange={(e) => {
setToken(e.target.value);
if (e.target.value) setPreviewAmount(100);
}}
onChange={(e) => setToken(e.target.value)}
placeholder="粘贴用户核销码"
/>
</div>
+36 -33
View File
@@ -1,44 +1,47 @@
/** Stitch 用户端-商品详情页 原型图(杜康·白水古酿) */
const STITCH_CAROUSEL = [
'https://lh3.googleusercontent.com/aida-public/AB6AXuDiJm2VWrwCv8wnC-dBgSfvlf66izs6faELgWXlyIAUpYWOKrLwfeyB0c0XT0vmDVJnfIkzbLNm_4NYASwH_ce7BotJDLCJcd3SfnxKIe7eso-c4mzzR-4LTv4y3ELhpXHfxVyu-5LVUEwuofvUuzdJELV6CK4MIcLW_9rMaOuXSADfz0mpP-MspQvhKhxJ0wpdAiBxBq8rqHNSKjx8dU7lcVc_smZGunmtkbhmnjAn4JU8nCDnvuDU5HECng82FbFM6rzdkFHoYR0',
'https://lh3.googleusercontent.com/aida-public/AB6AXuDzBBRn0yOqhRJ4CbTEOx1aF4fJVIhsbIZgFR9RgdB5E0xcs_RdR1khLyR0OzysGzkW_tnrZTb0avVEZ91Nd81KRItrlTjrEFvrYj0Qag45iRo5wioY8E2gK5NGhILvDpWxakuSPIGGp00nLY_5HuuLwr-0_8ZabaUFAR4C9loXIX_lgCAgRMt7An_H0AitIOBvwOfNVTMkz-P7dXQFzSUvYpFvcmvzAOIWsbipnTrgNU5H8Os37-soM-eWUCfNtJUaD_uqQ3mwJI8',
'https://lh3.googleusercontent.com/aida-public/AB6AXuDSSmH1ygwminKXiiIqOymnukbKJfnhfHnmCJTbNN2BEN2yF3vPtoMYOBAsDHxuldT9xg_ZBZhjh6QJjabvhu_HFB3WcNU53q_AjsD0mVWXInongiXqjOh8R-B2QW9Jfs786j3TSi2gVE57Ad1WskJji-xytI3aFEuk873xGXgdkn6EgzoAMOsKRaWF27DE3GBa48qAARYR92aEyMU_hcte6L2lkaF9brXshSmujiA_3ACK21TLsT2DCJ1Djacvh25J0LZ8v7BYVkk',
] as const;
/** 商品无图时的占位图 */
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
const STITCH_DETAIL = [
'https://lh3.googleusercontent.com/aida-public/AB6AXuA2-IVt-apnEkj9QQ4rkjN5lb0oymgiJX1XfzAH8pRzSFzMVjYDtlMWE8GwpS7I6sth7CXJiKwNm9c-hpsYKqQ6pyb48yUO6NG8vky4E6qCjwgaCsCnlvoOVLroG4bmmL16-xl4-o28ZMvtzMoCKIiUK-_dQF7lx66nwnxFOP7PcUddDK-UItoO-Gp5iqxf6kp_-t_tjoPpo_ba25DBPG1QThlI8IJYqb9bNng5mIQzdnNul24rBy_JmgS5nsaYK0Wvo7907WT3ch4',
'https://lh3.googleusercontent.com/aida-public/AB6AXuBe0yoN6VDKu2SVqwOy9RedE-6Rh56-a_5ygo5raDOU3Y65m1fz9hNmqbrIpvTW8RsqAwPd3zfHnw96Bb4Ct7jtmq-pil1MEvPBL4G3C8Sym_LVuEzK---hgdim1wVx-qP1v1EPex2fXpVQEY27rEVINVaXk2L1F5elWKQhMHWVXjU8B2jvtyNzmlXBpynsocnCgcwM4RhaqYdVf1JZxcfScmJ34dO3QAUIli-RPzEYynLtnW2x4lEbRrpAdBnK2fuSggLnJU1nfSw',
] as const;
export type ProductImageSource = {
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
detailImageUrls?: string[] | null;
};
/** 本地商品图占位 */
export const PRODUCT_IMAGE_INDEX = [
'/images/1.png',
'/images/2.png',
'/images/3.png',
] as const;
export function getProductImages(productIndex = 0): string[] {
if (productIndex === 0) {
return [...STITCH_CAROUSEL.slice(0, 2)];
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const result: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
result.push(url);
}
const img = PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
return [img];
return result;
}
export function getProductMainImage(productIndex = 0): string {
if (productIndex === 0) return STITCH_CAROUSEL[0];
return PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
export function getProductImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
const main = source?.mainImageUrl;
if (main) return [main];
return [PRODUCT_IMAGE_FALLBACK];
}
/** 详情页轮播(首商品用 Stitch 三图,其余单图) */
export function getProductCarouselImages(productIndex = 0): string[] {
if (productIndex === 0) return [...STITCH_CAROUSEL];
const img = getProductMainImage(productIndex);
return [img];
/** 单张主图:封面优先 */
export function getProductMainImage(source?: ProductImageSource | null): string {
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
}
/** 详情页顶部轮播 */
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
return getProductImages(source);
}
/** 详情页图文长图 */
export function getProductDetailImages(productIndex = 0): string[] {
if (productIndex === 0) return [...STITCH_DETAIL];
return [];
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
return uniqueUrls(source?.detailImageUrls ?? []);
}
+38 -5
View File
@@ -13,27 +13,50 @@ type Product = {
subtitle: string;
price: number;
benefitDisplay: number;
mainImageUrl: string;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
detailImageUrls?: string[] | null;
aromaType: string;
status: string;
};
type City = {
id: string;
code: string;
name: string;
status: string;
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型', open: true },
{ key: 'JIANGXIANG', label: '酱香型', open: false },
{ key: 'NONGXIANG', label: '浓香型', open: false },
];
const CITY_STORAGE_KEY = 'dukang_selected_city';
export default function HomePage() {
const [tab, setTab] = useState('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
const [toast, setToast] = useState('');
useEffect(() => {
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
setCities(list);
if (!list.some((c) => c.code === cityCode) && list[0]) {
setCityCode(list[0].code);
}
});
}, []);
useEffect(() => {
if (!cityCode) return;
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
}, [cityCode]);
function showToast(message: string) {
setToast(message);
window.setTimeout(() => setToast(''), 2200);
@@ -47,6 +70,7 @@ export default function HomePage() {
setTab(key);
}
const selectedCity = cities.find((c) => c.code === cityCode);
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
@@ -57,7 +81,16 @@ export default function HomePage() {
extra={(
<div className="tab-main-city">
<span className="material-symbols-outlined">location_on</span>
<span></span>
<select
value={cityCode}
onChange={(e) => setCityCode(e.target.value)}
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
>
{cities.map((c) => (
<option key={c.code} value={c.code}>{c.name}</option>
))}
{!cities.length && <option value={cityCode}>{selectedCity?.name ?? '郑州市'}</option>}
</select>
</div>
)}
/>
@@ -78,10 +111,10 @@ export default function HomePage() {
<section className="home-product-list">
{!onSale && <div className="home-empty">线</div>}
{onSale &&
filtered.map((p, index) => (
filtered.map((p) => (
<article key={p.id} className="home-product-card">
<Link to={`/product/${p.id}`} className="home-product-link">
<ProductCarousel images={getProductImages(index)} alt={p.name} />
<ProductCarousel images={getProductImages(p)} alt={p.name} />
<div className="home-product-body">
<div className="home-product-row">
<h3 className="home-product-name">{p.name}</h3>
+3 -5
View File
@@ -4,8 +4,6 @@ import SubPageHeader from '../components/SubPageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { buildProductDetailUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { tryGetClientGpsLocation } from '../lib/client-location';
import { getProductMainImage } from '../lib/product-images';
import PhoneVerifySheet from '../components/PhoneVerifySheet';
import { useUserSession } from '../contexts/UserSessionContext';
@@ -27,6 +25,8 @@ type PreviewProduct = {
spec: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
@@ -113,9 +113,7 @@ export default function OrderConfirmPage() {
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
const productImage =
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
async function doSubmit() {
const clientLocation = await tryGetClientGpsLocation();
@@ -145,6 +145,7 @@ export default function OrderDetailPage() {
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0);
@@ -174,6 +175,22 @@ export default function OrderDetailPage() {
}
}
async function requestRefund() {
if (!id || !canRefund) return;
setConfirming(true);
try {
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
method: 'POST',
body: JSON.stringify({ remark: '用户申请退款' }),
});
await loadOrder();
} catch (e) {
window.alert(e instanceof Error ? e.message : '申请退款失败');
} finally {
setConfirming(false);
}
}
if (!order) return <div className="empty">...</div>;
return (
@@ -380,6 +397,11 @@ export default function OrderDetailPage() {
<span className="material-symbols-outlined">headset_mic</span>
</button>
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
退
</button>
)}
{canConfirmReceive && (
<button
type="button"
+4 -4
View File
@@ -4,8 +4,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
import ProductCarousel from '../components/ProductCarousel';
import { request } from '../lib/api';
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
import type { ProductImageSource } from '../lib/product-images';
type Product = {
type Product = ProductImageSource & {
name: string;
subtitle?: string;
price: number;
@@ -22,7 +23,6 @@ export default function ProductDetailPage() {
const navigate = useNavigate();
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const imageIndex = id ? Math.max(0, Number(id) - 1) : 0;
useEffect(() => {
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
@@ -39,8 +39,8 @@ export default function ProductDetailPage() {
if (!product) return <div className="empty">...</div>;
const benefit = Number(product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(imageIndex);
const detailImages = getProductDetailImages(imageIndex);
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
return (
<div className="product-detail-page">
+20 -2
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
@@ -5,6 +6,19 @@ export default function RedeemCodePage() {
const navigate = useNavigate();
const token = sessionStorage.getItem('redeemToken') || '';
const amount = sessionStorage.getItem('redeemAmount') || '0';
const [secondsLeft, setSecondsLeft] = useState(300);
useEffect(() => {
const expireAt = sessionStorage.getItem('redeemExpireAt');
if (!expireAt) return;
const tick = () => {
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
setSecondsLeft(left);
};
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, []);
return (
<div className="page-no-tab" style={{ textAlign: 'center' }}>
@@ -14,10 +28,14 @@ export default function RedeemCodePage() {
<div className="label-md text-muted"></div>
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
<div className="code-box">{token}</div>
<p className="label-md text-muted" style={{ marginTop: 16 }}>5 </p>
<p className="label-md text-muted" style={{ marginTop: 16 }}>
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/redeem/success')}></button>
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
</button>
</div>
</div>
);
+3 -2
View File
@@ -1,7 +1,8 @@
# 杜康好客 · 协同与开发规范
> **编码事实源**:根目录 [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)PRD + DB v3.1 + API + 计划 + 架构)
> **preV1 Mock 联调**[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
> **V3 交付验收**[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)
> **完整蓝图**[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)(与 V3 冲突时 V3 优先)
> **preV1 历史**[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
---
+34
View File
@@ -0,0 +1,34 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default tseslint.config(
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/prisma/**',
'pages/**',
'deploy/**',
'**/*.config.js',
'**/*.config.ts',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'prefer-const': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
{
files: ['scripts/**/*.mjs'],
languageOptions: {
globals: globals.node,
},
},
);
+12 -2
View File
@@ -1,6 +1,12 @@
{
"name": "dukang-haoke",
"private": true,
"devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"globals": "^15.14.0",
"typescript-eslint": "^8.18.0"
},
"scripts": {
"dev": "pnpm -r --parallel dev",
"dev:api": "pnpm --filter @dukang/api dev",
@@ -9,7 +15,8 @@
"dev:partner": "pnpm --filter @dukang/h5-partner dev",
"dev:admin": "pnpm --filter @dukang/admin-web dev",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"lint": "eslint .",
"typecheck": "pnpm -r exec tsc --noEmit",
"test": "pnpm -r test",
"db:generate": "pnpm --filter @dukang/api prisma:generate",
"db:migrate": "pnpm --filter @dukang/api prisma:migrate",
@@ -17,7 +24,10 @@
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
"db:validate": "pnpm --filter @dukang/api prisma:validate",
"oss:cors": "node scripts/configure-oss-cors.mjs",
"sync:stitch": "node scripts/sync-stitch.mjs"
"sync:stitch": "node scripts/sync-stitch.mjs",
"smoke": "node scripts/smoke-v3.mjs",
"smoke:v3": "node scripts/smoke-v3.mjs",
"smoke:prev1": "node scripts/smoke-prev1.mjs"
},
"engines": {
"node": ">=20"
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"lint": "echo ok"
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.4.5",
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "echo ok"
"lint": "eslint src"
},
"devDependencies": {
"typescript": "^5.4.5"
+21
View File
@@ -0,0 +1,21 @@
export interface BenefitCouponDto {
id: string;
couponNo: string;
balance: number;
totalAmount: number;
status: string;
sourceProduct?: string;
}
export interface BenefitSummaryDto {
totalBalance: number;
couponCount: number;
}
export interface BenefitLedgerDto {
id: string;
type: string;
amount: number;
balanceAfter: number;
createdAt: string;
}
+30
View File
@@ -0,0 +1,30 @@
export interface CityDto {
id: string;
code: string;
name: string;
province: string;
status: string;
}
export interface ProductDto {
id: string;
skuCode: string;
name: string;
subtitle?: string | null;
price: number;
benefitAmount: number;
benefitDisplay?: number;
aromaType: string;
status: string;
/** 封面图(common_resource COVER / cover_resource_id */
mainImageUrl?: string | null;
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
carouselUrls?: string[];
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
}
export interface ProductListQuery {
cityCode?: string;
aromaType?: string;
}
+7
View File
@@ -2,3 +2,10 @@ export * from './enums';
export * from './api';
export * from './config';
export * from './wechat';
export * from './catalog';
export * from './trade';
export * from './benefit';
export * from './redeem';
export * from './settlement';
export * from './ops';
export * from './ticket';
+15
View File
@@ -0,0 +1,15 @@
export interface AdminListQuery {
page?: number;
pageSize?: number;
}
export interface AdminPageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface ExportRequest {
format?: 'csv';
}
+28
View File
@@ -0,0 +1,28 @@
export interface RedeemTokenRequest {
amount: number;
couponId?: string;
storeId?: string;
}
export interface RedeemTokenResult {
token: string;
expireAt: string;
amount: number;
boundStoreId?: string | null;
}
export interface RedeemPreviewDto {
token: string;
amount: number;
expireInSeconds: number;
redeemType?: 'DIRECT' | 'COUPON';
boundStoreId?: string | null;
}
export interface RedeemRecordDto {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
createdAt: string;
}
+25
View File
@@ -0,0 +1,25 @@
export interface StorePayoutDto {
id: string;
redeemAmount: number;
payoutAmount: number;
status: 'PENDING' | 'PAID';
expectedPayAt: string;
paidAt?: string | null;
}
export interface PartnerBillDto {
id: string;
billNo: string;
orderCommission: number;
redeemCommission: number;
totalAmount: number;
status: 'DRAFT' | 'CONFIRMED' | 'PAID';
periodStart: string;
periodEnd: string;
}
export interface PartnerBillDetailDto extends PartnerBillDto {
partnerId: string;
confirmedAt?: string | null;
paidAt?: string | null;
}
+16
View File
@@ -0,0 +1,16 @@
export type TicketTypeDto = 'REFUND' | 'RESHIPMENT' | 'ALERT';
export interface TicketDto {
id: string;
ticketNo: string;
ticketType: TicketTypeDto;
status: string;
refType: string;
refId: string;
remark?: string | null;
createdAt: string;
}
export interface TicketActionRequest {
remark?: string;
}
+35
View File
@@ -0,0 +1,35 @@
export interface OrderDto {
id: string;
orderNo: string;
status: string;
payStatus: string;
payAmount: number;
benefitAmount: number;
quantity: number;
productName: string;
createdAt: string;
}
export interface OrderPreviewRequest {
productId: string;
quantity: number;
addressId?: string;
}
export interface OrderPreviewResult {
productAmount: number;
payAmount: number;
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
}
export interface PayOrderResult {
mode?: 'jsapi' | 'mock';
orderId?: string;
}
export interface DeliveryDto {
provider?: string;
shippingAt?: string;
deliveredAt?: string;
}
+667 -1
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
return json.data;
}
async function expectFail(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code === 0) throw new Error(`${path}: expected failure`);
return json.message;
}
async function adminLogin() {
return req('HQ_WEB', '/admin/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13600000001', code: '123456' }),
});
}
async function main() {
console.log('1. Health');
await req('USER_H5', '/health');
console.log('2. User login');
const userLogin = await req('USER_H5', '/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
});
const userToken = userLogin.accessToken;
console.log('3. Cities + products');
const cities = await req('USER_H5', '/catalog/cities');
const cityCode = cities[0]?.code ?? 'ZZ';
const products = await req('USER_H5', `/catalog/products?cityCode=${cityCode}`);
if (products.length < 4) throw new Error(`Expected >=4 products, got ${products.length}`);
console.log('4. Min purchase boundaries');
const addr = await req('USER_H5', '/user/addresses', {
method: 'POST',
token: userToken,
body: JSON.stringify({
receiverName: '测试',
phone: '13800000001',
province: '河南省',
city: '郑州市',
district: '金水区',
detail: 'V3冒烟地址',
isDefault: true,
}),
});
await expectFail('USER_H5', '/trade/orders/preview', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 1, addressId: addr.id }),
});
const preview2 = await req('USER_H5', '/trade/orders/preview', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
if (preview2.payAmount <= 0) throw new Error('Preview qty=2 failed');
console.log('5. Create order + pay + benefit');
const order = await req('USER_H5', '/trade/orders', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
const paidOrder = await req('USER_H5', `/trade/orders/${order.id}`, { token: userToken });
if (paidOrder.status !== 'PENDING_SHIP') throw new Error('Order not PENDING_SHIP after pay');
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
if (!coupons.length) throw new Error('No coupon after pay');
const summary = await req('USER_H5', '/benefit/summary', { token: userToken });
console.log('6. Direct redeem (no couponId)');
const directAmount = Math.min(100, Number(summary.totalBalance ?? summary.balance ?? 100));
const directToken = await req('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ amount: directAmount }),
});
if (!directToken.token) throw new Error('Direct redeem token missing');
console.log('7. Coupon redeem cap');
await expectFail('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
});
console.log('8. Shop confirm redeem');
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
});
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
token: shopLogin.accessToken,
body: JSON.stringify({ token: directToken.token }),
});
if (!preview.amount) throw new Error('Redeem preview failed');
await req('SHOP_H5', '/shop/redeem/confirm', {
method: 'POST',
token: shopLogin.accessToken,
body: JSON.stringify({ token: directToken.token }),
});
console.log('9. Admin login + store payout');
const admin = await adminLogin();
const payouts = await req('HQ_WEB', '/admin/store-payouts?status=PENDING', {
token: admin.accessToken,
});
if (!payouts.items?.length) throw new Error('Expected pending store payout');
const payoutId = payouts.items[0].id;
await req('HQ_WEB', `/admin/store-payouts/${payoutId}/confirm`, {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({ remark: 'smoke confirm' }),
});
console.log('10. Refund ticket flow');
const order2 = await req('USER_H5', '/trade/orders', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
await req('USER_H5', `/trade/orders/${order2.id}/pay`, { method: 'POST', token: userToken });
await req('USER_H5', `/trade/orders/${order2.id}/refund-requests`, {
method: 'POST',
token: userToken,
body: JSON.stringify({ remark: 'smoke refund' }),
});
const tickets = await req('HQ_WEB', '/admin/tickets?ticketType=REFUND', {
token: admin.accessToken,
});
const refundTicket = tickets.items?.find((t) => t.refId === order2.id);
if (!refundTicket) throw new Error('Refund ticket not created');
await req('HQ_WEB', `/admin/tickets/${refundTicket.id}/approve`, {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({}),
});
console.log('11. Partner bill');
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
const partnerId = partners.items?.[0]?.id;
if (partnerId) {
const now = new Date();
await req('HQ_WEB', '/admin/partner-bills/generate', {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({ partnerId, year: now.getFullYear(), month: now.getMonth() + 1 }),
});
}
console.log('\n✅ V3 smoke passed');
}
main().catch((e) => {
console.error('❌ V3 smoke failed:', e.message);
process.exit(1);
});
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main",
"lint": "echo ok",
"lint": "eslint src",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { IntegrationsModule } from '../integrations/integrations.module';
import { TradeModule } from '../modules/trade/trade.module';
import { PrismaModule } from '../common/prisma/prisma.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller';
import { DeliveryCallbackController } from './delivery-track.controller';
@Module({
imports: [IntegrationsModule, TradeModule],
controllers: [WechatPayCallbackController],
imports: [IntegrationsModule, TradeModule, PrismaModule],
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
})
export class CallbacksModule {}
@@ -0,0 +1,36 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TradeService } from '../modules/trade/trade.service';
import { CourierService } from '../integrations/courier/courier.service';
import { PrismaService } from '../common/prisma/prisma.module';
@Controller('callbacks/delivery')
export class DeliveryCallbackController {
constructor(
private readonly tradeService: TradeService,
private readonly courier: CourierService,
private readonly prisma: PrismaService,
) {}
@Post('track')
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
if (!body.orderId && !body.orderNo) {
return this.courier.buildTrackCallbackResponse(false);
}
const order = body.orderId
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
if (!order) return this.courier.buildTrackCallbackResponse(false);
const statusMap: Record<string, string> = {
SHIPPED: 'SHIPPING',
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
DELIVERED: 'COMPLETED',
COMPLETED: 'COMPLETED',
};
const target = statusMap[body.status ?? ''] ?? body.status;
if (target && target !== order.status) {
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
}
return this.courier.buildTrackCallbackResponse(true);
}
}
@@ -0,0 +1,44 @@
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { PrismaService } from '../common/prisma/prisma.module';
@Controller('callbacks/wechat')
export class WechatRefundCallbackController {
constructor(private readonly prisma: PrismaService) {}
@Post('refund')
async refundNotify(
@Req() req: Request,
@Headers() _headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
try {
const body = typeof req.body === 'object' ? req.body : {};
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
const existing = await this.prisma.logThirdParty.findFirst({
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
});
if (existing) {
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
}
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_REFUND',
scene: 'ORDER_REFUND_CALLBACK',
refType: 'TICKET',
refId: BigInt(0),
externalNo: refundId,
status: 'SUCCESS',
},
});
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
const message = err instanceof Error ? err.message : '处理失败';
return res.status(500).json({ code: 'FAIL', message });
}
}
}
@@ -26,4 +26,8 @@ export class RedisService {
async del(key: string) {
await this.redis.del(key);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
}
@@ -1,9 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
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';
export type CouponAllocation = { couponId: string; amount: number };
@Injectable()
export class BenefitService {
constructor(private readonly prisma: PrismaService) {}
@@ -85,4 +88,73 @@ export class BenefitService {
});
return serializeBigInt({ coupon, ledgers });
}
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
async deductCoupons(
tx: Prisma.TransactionClient,
allocations: CouponAllocation[],
refType: 'STORE',
refId: bigint,
) {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = alloc.amount;
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
},
});
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType,
refId,
}),
});
}
}
/** 退款作废权益 */
async voidCouponsOnRefund(orderId: bigint) {
const coupons = await this.prisma.benefitCoupon.findMany({
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
});
for (const coupon of coupons) {
const balance = Number(coupon.balance);
if (balance <= 0 && coupon.status === 'USED_UP') continue;
await this.prisma.$transaction(async (tx) => {
await tx.benefitCoupon.update({
where: { id: coupon.id },
data: { status: 'VOID', balance: 0 },
});
if (balance > 0) {
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REFUND_VOID',
amount: -balance,
balanceAfter: 0,
refType: 'ORDER',
refId: orderId,
remark: '退款作废权益',
}),
});
}
});
}
}
}
@@ -11,8 +11,8 @@ export class CatalogController {
}
@Get('products')
products(@Query('aromaType') aromaType?: string) {
return this.catalogService.listProducts(aromaType);
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
return this.catalogService.listProducts(aromaType, cityCode);
}
@Get('products/:id')
@@ -0,0 +1,62 @@
import type { CommonProductItem, CommonResource } from '@prisma/client';
export type ProductMediaDto = {
mainImageUrl: string | null;
carouselUrls: string[];
detailImageUrls: string[];
};
type ProductWithCover = CommonProductItem & {
coverResource?: { url: string } | null;
};
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
return resources
.filter((r) => r.bizType === bizType && r.url)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((r) => r.url);
}
export function mapProductMedia(
product: ProductWithCover,
extraResources: CommonResource[] = [],
): ProductMediaDto {
const mainImageUrl = product.coverResource?.url ?? null;
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
const detailFromJson = parseDetailContentImages(product.detailContent);
const carouselUrls =
carouselFromDb.length > 0
? carouselFromDb
: mainImageUrl
? [mainImageUrl]
: [];
const detailImageUrls =
detailFromDb.length > 0
? detailFromDb
: detailFromJson;
return { mainImageUrl, carouselUrls, detailImageUrls };
}
function parseDetailContentImages(detailContent: unknown): string[] {
if (!detailContent || typeof detailContent !== 'object') return [];
const record = detailContent as Record<string, unknown>;
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
if (!Array.isArray(images)) return [];
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
export function groupResourcesByProductId(resources: CommonResource[]) {
const map = new Map<string, CommonResource[]>();
for (const resource of resources) {
const key = resource.ownerId.toString();
const list = map.get(key) ?? [];
list.push(resource);
map.set(key, list);
}
return map;
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
@Injectable()
export class CatalogService {
@@ -10,24 +11,50 @@ export class CatalogService {
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
orderBy: { name: 'asc' },
});
return serializeBigInt(cities);
}
async listProducts(aromaType?: string) {
async listProducts(aromaType?: string, cityCode?: string) {
if (cityCode) {
const city = await this.prisma.commonCity.findFirst({
where: { code: cityCode, status: 'ACTIVE' },
});
if (!city) throw new BadRequestException('该城市暂未开城');
}
const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
include: { coverResource: true },
});
const productIds = products.map((p) => p.id);
const resources = productIds.length
? await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: { in: productIds },
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
})
: [];
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt(
products.map((p) => ({
...p,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
mainImageUrl: p.coverResource?.url ?? null,
})),
products.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
return {
...p,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
...media,
};
}),
);
}
@@ -37,11 +64,23 @@ export class CatalogService {
include: { coverResource: true },
});
if (!product) return null;
const resources = await this.prisma.commonResource.findMany({
where: {
ownerType: 'PRODUCT',
ownerId: id,
status: 'ACTIVE',
bizType: { in: ['CAROUSEL', 'DETAIL'] },
},
orderBy: { sortOrder: 'asc' },
});
const media = mapProductMedia(product, resources);
return serializeBigInt({
...product,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
mainImageUrl: product.coverResource?.url ?? null,
...media,
});
}
}
@@ -316,7 +316,7 @@ export class AuthService {
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let user = await this.prisma.user.findFirst({
const user = await this.prisma.user.findFirst({
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
include: { avatar: true },
});
@@ -20,6 +20,9 @@ export class AdminDashboardService {
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
openTickets,
] = await Promise.all([
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
this.prisma.user.count({
@@ -38,6 +41,9 @@ export class AdminDashboardService {
this.prisma.partner.count(),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }),
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
]);
return {
@@ -50,6 +56,9 @@ export class AdminDashboardService {
partnersTotal,
redeemToday,
deliveriesTotal,
pendingPayouts,
pendingBills,
openTickets,
ordersByStatus: ordersByStatus.map((row) => ({
status: row.status,
count: row._count.status,
@@ -45,6 +45,11 @@ export class AdminStoresController {
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
return this.service.updateStoreStatus(BigInt(id), dto);
}
@Put(':id/audit')
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
return this.service.auditStore(BigInt(id), body);
}
}
@Controller('admin/store-accounts')
@@ -93,6 +93,27 @@ export class AdminStoresService {
return serializeBigInt(store);
}
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
const store = await this.prisma.store.findUnique({ where: { id } });
if (!store) throw new NotFoundException('门店不存在');
const status = dto.approved ? 'OPEN' : 'PAUSED';
const updated = await this.prisma.store.update({
where: { id },
data: { status },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: id,
actorType: 'HQ',
status: dto.approved ? 'APPROVED' : 'REJECTED',
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
},
});
return serializeBigInt(updated);
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
const store = await this.prisma.store.update({
where: { id },
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminTicketsService } from './admin-tickets.service';
import { TicketListQueryDto } from '../common/dto/common-query.dto';
@Controller('admin/tickets')
@UseGuards(HqAuthGuard)
export class AdminTicketsController {
constructor(private readonly service: AdminTicketsService) {}
@Get()
list(@Query() query: TicketListQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post(':id/approve')
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.approve(BigInt(id), body.remark);
}
@Post(':id/reject')
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
return this.service.reject(BigInt(id), body.remark);
}
}
@@ -0,0 +1,107 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { BenefitService } from '../benefit/benefit.service';
import { TradeService } from '../trade/trade.service';
import { TicketService } from '../common/ticket.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
@Injectable()
export class AdminTicketsService {
constructor(
private readonly prisma: PrismaService,
private readonly ticketService: TicketService,
private readonly tradeService: TradeService,
private readonly benefitService: BenefitService,
) {}
list(query: TicketListQueryDto) {
return this.ticketService.list(query);
}
detail(id: bigint) {
return this.ticketService.detail(id);
}
async approve(id: bigint, remark?: string) {
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('工单不存在');
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
throw new BadRequestException('工单状态不可审批');
}
if (ticket.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
const orderId = ticket.refId;
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
});
await this.benefitService.voidCouponsOnRefund(orderId);
await this.prisma.logThirdParty.create({
data: {
provider: 'WECHAT_REFUND',
scene: 'ORDER_REFUND',
refType: 'ORDER',
refId: orderId,
status: 'SUCCESS',
amount: 0,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'ORDER_STATUS',
refType: 'ORDER',
refId: orderId,
actorType: 'HQ',
status: 'REFUNDED',
remark: remark ?? '退款工单审批通过',
},
});
}
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
await this.tradeService.applyStatusTransition(
ticket.refId,
'PENDING_SHIP',
'PENDING_SHIP',
'HQ',
);
}
return this.ticketService.updateStatus(id, {
status: 'RESOLVED',
remark: remark ?? '审批通过',
});
}
reject(id: bigint, remark?: string) {
return this.ticketService.updateStatus(id, {
status: 'REJECTED',
remark: remark ?? '审批驳回',
});
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
select: { id: true },
});
const orderIds = orders.map((o) => o.id);
const tickets = await this.prisma.commonTicket.findMany({
where: {
ticketType: 'RESHIPMENT',
refType: 'ORDER',
refId: { in: orderIds },
},
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(tickets);
}
}
@@ -21,10 +21,14 @@ 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 { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@Module({
imports: [IamModule, TradeModule],
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -41,6 +45,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
AdminTicketsController,
],
providers: [
AdminDashboardService,
@@ -54,6 +59,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
AdminTicketsService,
SuperAdminGuard,
],
})
@@ -29,6 +29,11 @@ export class UserRedeemController {
export class ShopRedeemController {
constructor(private readonly redeemService: RedeemService) {}
@Post('preview')
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.previewRedeem(user.actorId, body.token);
}
@Post('confirm')
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.confirmRedeem(user.actorId, body);
@@ -1,11 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { SettlementModule } from '../settlement/settlement.module';
import { RedeemService } from './redeem.service';
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
@Module({
imports: [IamModule, forwardRef(() => SettlementModule)],
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
controllers: [UserRedeemController, ShopRedeemController],
providers: [RedeemService],
exports: [RedeemService],
@@ -15,7 +15,15 @@ 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';
import { BenefitService } from '../benefit/benefit.service';
type TokenPayload = {
userId: string;
couponId?: string;
amount: number;
storeId?: string | null;
allocations?: Array<{ couponId: string; amount: number }>;
};
@Injectable()
export class RedeemService {
@@ -23,6 +31,7 @@ export class RedeemService {
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly settlementService: SettlementService,
private readonly benefitService: BenefitService,
) {}
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
@@ -42,6 +51,7 @@ export class RedeemService {
where: { userId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
const result = allocateBenefitCoupons(
coupons.map((c) => ({
id: c.id.toString(),
@@ -51,6 +61,8 @@ export class RedeemService {
body.amount,
);
if (!result.ok) throw new BadRequestException(result.message);
const check = validateRedeemAmount(totalBalance, body.amount);
if (!check.ok) throw new BadRequestException(check.message);
allocations = result.allocations;
}
@@ -70,7 +82,7 @@ export class RedeemService {
REDEEM_TOKEN_TTL_SECONDS,
);
return { token, expireAt, amount: body.amount };
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
}
async getToken(token: string) {
@@ -79,6 +91,40 @@ export class RedeemService {
return cached;
}
async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const user = await this.prisma.user.findUnique({
where: { id: BigInt(cached.userId) },
select: { id: true, userNo: true, phone: true, nickname: true },
});
const ttl = await this.redis.ttl(`redeem:token:${token}`);
return serializeBigInt({
token,
amount: cached.amount,
user,
boundStoreId: cached.storeId,
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
expireInSeconds: ttl > 0 ? ttl : 0,
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
});
}
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
@@ -88,19 +134,16 @@ export class RedeemService {
throw new BadRequestException('门店未营业');
}
const cached = await this.redis.getJson<{
userId: string;
couponId?: string;
amount: number;
allocations?: Array<{ couponId: string; amount: number }>;
}>(`redeem:token:${body.token}`);
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
throw new BadRequestException('该核销码仅限指定门店使用');
}
const allocations =
cached.allocations ??
(cached.couponId
? [{ couponId: cached.couponId, amount: cached.amount }]
: []);
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
if (allocations.length === 0) {
throw new BadRequestException('核销码数据异常');
}
@@ -126,50 +169,30 @@ export class RedeemService {
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const record = await this.prisma.$transaction(async (tx) => {
for (const alloc of allocations) {
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
where: { id: BigInt(alloc.couponId) },
});
const allocAmount = alloc.amount;
const updated = await tx.benefitCoupon.updateMany({
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
let record;
try {
record = await this.prisma.$transaction(async (tx) => {
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
const redeemRecord = await tx.redeemRecord.create({
data: {
usedAmount: { increment: allocAmount },
balance: { decrement: allocAmount },
version: { increment: 1 },
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
redeemNo: generateRedeemNo(),
userId: BigInt(cached.userId),
couponId: BigInt(allocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
});
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
const newBalance = Number(coupon.balance) - allocAmount;
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
amount: -allocAmount,
balanceAfter: newBalance,
refType: 'STORE',
refId: account.storeId,
}),
});
}
const redeemRecord = await tx.redeemRecord.create({
data: {
redeemNo: generateRedeemNo(),
userId: BigInt(cached.userId),
couponId: BigInt(allocations[0].couponId),
storeId: account.storeId,
amount,
settleAmount,
},
return redeemRecord;
});
return redeemRecord;
});
} catch (e) {
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
throw new BadRequestException('核销失败,请重试');
}
throw e;
}
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
await this.redis.del(`redeem:token:${body.token}`);
@@ -187,6 +210,7 @@ export class RedeemService {
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { payout: true },
}),
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
]);
@@ -1,7 +1,8 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PrismaService } from '../../common/prisma/prisma.module';
@Controller('partner/settlement')
@@ -15,6 +16,101 @@ export class SettlementController {
}
}
@Controller('shop/payouts')
@UseGuards(JwtAuthGuard)
export class ShopPayoutController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
}
}
@Controller('admin/store-payouts')
@UseGuards(HqAuthGuard)
export class AdminStorePayoutController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStorePayouts({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
storeId: query.storeId,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminStorePayout(BigInt(id));
}
@Post(':id/confirm')
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
return this.settlementService.confirmStorePayout(BigInt(id), body);
}
@Post('batch-confirm')
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
}
@Post('scan-due')
scanDue() {
return this.settlementService.scanDueStorePayouts();
}
}
@Controller('admin/partner-bills')
@UseGuards(HqAuthGuard)
export class AdminPartnerBillController {
constructor(private readonly settlementService: SettlementService) {}
@Post('generate')
generate(@Body() body: { partnerId: string; year: number; month: number }) {
return this.settlementService.generatePartnerBill(body);
}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminPartnerBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
partnerId: query.partnerId,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportPartnerBills({
partnerId: query.partnerId,
status: query.status,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminPartnerBill(BigInt(id));
}
@Post(':id/confirm')
confirm(@Param('id') id: string) {
return this.settlementService.confirmPartnerBill(BigInt(id));
}
@Post(':id/mark-paid')
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
}
}
@Controller('partner/me')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
@@ -1,11 +1,23 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { SettlementService } from './settlement.service';
import { PartnerMeController, SettlementController } from './settlement.controller';
import {
AdminPartnerBillController,
AdminStorePayoutController,
PartnerMeController,
SettlementController,
ShopPayoutController,
} from './settlement.controller';
@Module({
imports: [IamModule],
controllers: [SettlementController, PartnerMeController],
controllers: [
SettlementController,
PartnerMeController,
ShopPayoutController,
AdminStorePayoutController,
AdminPartnerBillController,
],
providers: [SettlementService],
exports: [SettlementService],
})
@@ -1,7 +1,12 @@
import { Injectable } from '@nestjs/common';
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';
function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
@Injectable()
export class SettlementService {
constructor(private readonly prisma: PrismaService) {}
@@ -29,6 +34,106 @@ export class SettlementService {
return serializeBigInt(payout);
}
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
});
const where = { storeId: account.storeId };
const [items, total] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
}),
this.prisma.storePayout.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async listAdminStorePayouts(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StorePayoutWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
if (query.storeId) where.storeId = BigInt(query.storeId);
const [items, total] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: { expectedPayAt: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true } },
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
},
}),
this.prisma.storePayout.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async getAdminStorePayout(id: bigint) {
const payout = await this.prisma.storePayout.findUnique({
where: { id },
include: {
store: true,
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
},
});
if (!payout) throw new NotFoundException('打款记录不存在');
return serializeBigInt(payout);
}
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
if (!payout) throw new NotFoundException('打款记录不存在');
if (payout.status === 'PAID') throw new BadRequestException('已打款');
const updated = await this.prisma.storePayout.update({
where: { id },
data: {
status: 'PAID',
paidAt: new Date(),
batchNo: dto.batchNo ?? payout.batchNo,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'STORE_PAYOUT',
refId: id,
actorType: 'HQ',
status: 'PAID',
param1: dto.paymentRef ?? '',
remark: dto.remark ?? '门店 T+1 打款确认',
},
});
return serializeBigInt(updated);
}
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async listPartnerBills(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
@@ -39,4 +144,203 @@ export class SettlementService {
});
return serializeBigInt(bills);
}
async listAdminPartnerBills(query: {
page?: number;
pageSize?: number;
status?: string;
partnerId?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerBill.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { partner: { select: { companyName: true } } },
}),
this.prisma.partnerBill.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partner: true },
});
if (!bill) throw new NotFoundException('账单不存在');
return serializeBigInt(bill);
}
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
const partnerId = BigInt(body.partnerId);
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.partnerBill.findFirst({
where: {
partnerId,
periodStart,
status: { not: 'DRAFT' },
},
});
if (existing && existing.status !== 'DRAFT') {
throw new BadRequestException('该月账单已确认,不可重复生成');
}
const cities = await this.prisma.commonCity.findMany({
where: { partnerId },
include: { commissionRule: true },
});
const cityIds = cities.map((c) => c.id);
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
? Number(cities[0].commissionRule.orderCommissionRate)
: 0.05;
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
? Number(cities[0].commissionRule.redeemCommissionRate)
: 0.03;
const orders = await this.prisma.order.findMany({
where: {
cityId: { in: cityIds },
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
},
});
const orderCommission = orders.reduce(
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
0,
);
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
const storeIds = stores.map((s) => s.id);
const redeems = await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
},
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
0,
);
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
const draft = await this.prisma.partnerBill.findFirst({
where: { partnerId, periodStart, status: 'DRAFT' },
});
const bill = draft
? await this.prisma.partnerBill.update({
where: { id: draft.id },
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
})
: await this.prisma.partnerBill.create({
data: {
billNo: generateBillNo(),
partnerId,
periodStart,
periodEnd,
orderCommission,
redeemCommission,
totalAmount,
status: 'DRAFT',
},
});
return serializeBigInt(bill);
}
async confirmPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'PARTNER_BILL',
refId: id,
actorType: 'HQ',
status: 'CONFIRMED',
amount1: Number(updated.totalAmount),
},
});
return serializeBigInt(updated);
}
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
await this.prisma.commonEvent.create({
data: {
eventType: 'HQ_OPERATION',
refType: 'PARTNER_BILL',
refId: id,
actorType: 'HQ',
status: 'PAID',
param1: dto.paymentRef ?? '',
amount1: Number(updated.totalAmount),
},
});
return serializeBigInt(updated);
}
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
const where: Prisma.PartnerBillWhereInput = {};
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partner: { select: { companyName: true } } },
orderBy: { createdAt: 'desc' },
});
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
const rows = bills.map((b) =>
[
b.billNo,
b.partner.companyName,
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission),
Number(b.redeemCommission),
Number(b.totalAmount),
b.status,
].join(','),
);
return { csv: [header, ...rows].join('\n'), count: bills.length };
}
async scanDueStorePayouts() {
const now = new Date();
const due = await this.prisma.storePayout.findMany({
where: { status: 'PENDING', expectedPayAt: { lte: now } },
take: 100,
});
return serializeBigInt({ dueCount: due.length, items: due });
}
}
@@ -55,6 +55,15 @@ export class TradeController {
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tradeService.confirmReceive(user.actorId, BigInt(id));
}
@Post(':id/refund-requests')
refundRequest(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
}
}
@Controller('partner/orders')
@@ -85,3 +94,14 @@ export class PartnerOrderController {
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
}
}
@Controller('partner/reshipments')
@UseGuards(JwtAuthGuard)
export class PartnerReshipmentController {
constructor(private readonly tradeService: TradeService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
@@ -2,12 +2,14 @@ import { Module, forwardRef } from '@nestjs/common';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { TradeController, PartnerOrderController } from './trade.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
controllers: [TradeController, PartnerOrderController],
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
})
@@ -14,7 +14,9 @@ import {
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
@@ -29,16 +31,16 @@ import type { Request } from 'express';
export class TradeService {
constructor(
private readonly prisma: PrismaService,
private readonly catalogService: CatalogService,
private readonly benefitService: BenefitService,
private readonly ticketService: TicketService,
private readonly ipGeoService: IpGeoService,
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: BigInt(body.productId) },
});
const product = await this.catalogService.getProduct(BigInt(body.productId));
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
@@ -73,7 +75,7 @@ export class TradeService {
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
return {
product: serializeBigInt(product),
product,
quantity: body.quantity,
deliveryType,
productAmount,
@@ -384,6 +386,47 @@ export class TradeService {
return this.getOrder(userId, orderId);
}
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在');
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
throw new BadRequestException('当前订单不可申请退款');
}
await this.prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
});
return this.ticketService.create({
ticketType: 'REFUND',
refType: 'ORDER',
refId: orderId.toString(),
remark: remark ?? '用户申请退款',
});
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
select: { id: true },
});
const tickets = await this.prisma.commonTicket.findMany({
where: {
ticketType: 'RESHIPMENT',
refType: 'ORDER',
refId: { in: orders.map((o) => o.id) },
},
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(tickets);
}
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
+2
View File
@@ -1,5 +1,7 @@
# 杜康好客 · V2 编码手册(完整规格 · 唯一事实源)
> **V3 交付提示**:当前交付验收以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准。本手册中「核销单次上限 ¥500」等规则已被 V3 替代(直接核销可达总余额 / 单据 cap)。
> **版本**:**V2**(完整四端 + 微信生态 + 真实第三方)
> **联调裁剪版**:见 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)(三端 H5 + Mock,同库同 API 契约)
> **用途**:V2 正式编码与 preV1 预留对齐的完整规格
+2
View File
@@ -1,5 +1,7 @@
# 杜康好客 · preV1 编码手册(Mock 联调版)
> **V3 交付提示**:preV1 不再作为交付验收标准。核销规则以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) §3 为准(无 ¥500 上限)。
> **版本**:preV1(在 V2 完整规格之上的**裁剪实现阶段**)
> **完整规格(V2**[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) — PRD、DB v3.1、API、任务卡均以 V2 为准
> **原则**:**不删表、不删 V2 API 路径、不改 V2 字段语义**preV1 用 Mock / Feature Flag / Seed 跳过外部依赖,开关关闭即切回 V2 真实流程。