diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc index 0524e85..c2c9067 100644 --- a/.cursor/rules/git-conventions.mdc +++ b/.cursor/rules/git-conventions.mdc @@ -11,7 +11,7 @@ Conventional Commits,scope = 端或模块名: ``` 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 ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b4357d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 43fb334..b6e5b9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 人) diff --git a/README.md b/README.md index 8eabd17..67a40e4 100644 --- a/README.md +++ b/README.md @@ -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` — 三端 H5(Vite + React) diff --git a/V3_ACCEPTANCE_CHECKLIST.md b/V3_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..bdd1607 --- /dev/null +++ b/V3_ACCEPTANCE_CHECKLIST.md @@ -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` 页面接入与验收。 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 9b68121..268d91a 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -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 ; @@ -49,6 +53,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index e9541f8..e6f5e98 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -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: , label: '合伙人结算' }, + { key: '/tickets', icon: , label: '工单中心' }, + { key: '/third-party-logs', icon: , label: '第三方日志' }, { key: '/deliveries', icon: , label: '配送单' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx new file mode 100644 index 0000000..a1d90f9 --- /dev/null +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -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>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/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 = [ + { 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' && } + {row.status === 'CONFIRMED' && } + + ), + }, + ]; + + return ( +
+ 合伙人结算(T+30) +
+ + +
+
{ setFilters(v); setPage(1); }}> + + + +
+ { setPage(p); setPageSize(ps); } }} /> + setGenOpen(false)} footer={null}> +
+ + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StorePayoutsPage.tsx b/apps/admin-web/src/pages/StorePayoutsPage.tsx new file mode 100644 index 0000000..ec4bf8c --- /dev/null +++ b/apps/admin-web/src/pages/StorePayoutsPage.tsx @@ -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>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/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 | 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 = [ + { 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) => ( + <> + + {row.status === 'PENDING' && ( + + )} + + ), + }, + ]; + + return ( +
+ 门店打款(T+1) +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + ¥{String(detail.redeemAmount)} + ¥{String(detail.payoutAmount)} + {String(detail.status)} + {fmtTime(String(detail.expectedPayAt))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx new file mode 100644 index 0000000..463fe72 --- /dev/null +++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx @@ -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>({}); + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/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 | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const columns: ColumnsType = [ + { 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) => ( + + ), + }, + ]; + + return ( +
+ 第三方日志 +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + {String(detail.provider)} + {String(detail.scene)} + {String(detail.status)} + {String(detail.externalNo ?? '—')} + {fmtTime(String(detail.createdAt))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/TicketsPage.tsx b/apps/admin-web/src/pages/TicketsPage.tsx new file mode 100644 index 0000000..30a4f6e --- /dev/null +++ b/apps/admin-web/src/pages/TicketsPage.tsx @@ -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>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/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 | 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 = [ + { 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) => ( + + ), + }, + ]; + + return ( +
+ 工单中心 +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( + <> + + + + ) : null}> + {detail && ( + + {String(detail.ticketNo)} + {String(detail.ticketType)} + {String(detail.status)} + {String(detail.refType)} #{String(detail.refId)} + {String(detail.remark ?? '—')} + + )} + + + ); +} diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index b6460a3..3f77ebf 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -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(null); + const [storeClosed, setStoreClosed] = useState(false); useEffect(() => { request>('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('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 (
@@ -57,6 +92,9 @@ export default function RedeemConfirmPage() {
+ {storeClosed && ( +

门店当前未营业,无法核销

+ )}
@@ -75,7 +113,7 @@ export default function RedeemConfirmPage() { 下单用户
- 待扫码确认 + {userLabel}
@@ -91,7 +129,7 @@ export default function RedeemConfirmPage() { confirmation_number - 好客权益 + 好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
@@ -102,8 +140,14 @@ export default function RedeemConfirmPage() {
有效期 - 永久 + {preview ? `${preview.expireInSeconds} 秒` : '—'}
+ {preview?.boundStoreId && ( +
+ 绑定门店 + 仅限指定门店 +
+ )} {msg &&

{msg}

} @@ -111,13 +155,13 @@ export default function RedeemConfirmPage() {

请核对金额后点击确认

@@ -127,10 +171,7 @@ export default function RedeemConfirmPage() {
{ - setToken(e.target.value); - if (e.target.value) setPreviewAmount(100); - }} + onChange={(e) => setToken(e.target.value)} placeholder="粘贴用户核销码" />
diff --git a/apps/h5-user/src/lib/product-images.ts b/apps/h5-user/src/lib/product-images.ts index e74f902..734cb91 100644 --- a/apps/h5-user/src/lib/product-images.ts +++ b/apps/h5-user/src/lib/product-images.ts @@ -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) { + const seen = new Set(); + 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 ?? []); } diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index 8640db5..945d764 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -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([]); + const [cities, setCities] = useState([]); + const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100'); const [toast, setToast] = useState(''); useEffect(() => { - request('USER_H5', '/catalog/products').then(setProducts); + request('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('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={(
location_on - 郑州市 +
)} /> @@ -78,10 +111,10 @@ export default function HomePage() {
{!onSale &&
该香型暂未上线,敬请期待
} {onSale && - filtered.map((p, index) => ( + filtered.map((p) => (
- +

{p.name}

diff --git a/apps/h5-user/src/pages/OrderConfirmPage.tsx b/apps/h5-user/src/pages/OrderConfirmPage.tsx index ebb74f9..44528b1 100644 --- a/apps/h5-user/src/pages/OrderConfirmPage.tsx +++ b/apps/h5-user/src/pages/OrderConfirmPage.tsx @@ -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(); diff --git a/apps/h5-user/src/pages/OrderDetailPage.tsx b/apps/h5-user/src/pages/OrderDetailPage.tsx index 6ebc7ce..eda061f 100644 --- a/apps/h5-user/src/pages/OrderDetailPage.tsx +++ b/apps/h5-user/src/pages/OrderDetailPage.tsx @@ -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
加载中...
; return ( @@ -380,6 +397,11 @@ export default function OrderDetailPage() { headset_mic 联系客服 + {canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && ( + + )} {canConfirmReceive && ( +
); diff --git a/conventions.md b/conventions.md index 774327a..f169134 100644 --- a/conventions.md +++ b/conventions.md @@ -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) --- diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..6f3a4dd --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, +); diff --git a/package.json b/package.json index 26aed1c..11a9023 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/domain/package.json b/packages/domain/package.json index 801347f..864fede 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -8,7 +8,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "lint": "echo ok" + "lint": "eslint src" }, "devDependencies": { "typescript": "^5.4.5", diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index dc15323..dfe0521 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "lint": "echo ok" + "lint": "eslint src" }, "devDependencies": { "typescript": "^5.4.5" diff --git a/packages/shared-types/src/benefit.ts b/packages/shared-types/src/benefit.ts new file mode 100644 index 0000000..633550d --- /dev/null +++ b/packages/shared-types/src/benefit.ts @@ -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; +} diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts new file mode 100644 index 0000000..2eefd4c --- /dev/null +++ b/packages/shared-types/src/catalog.ts @@ -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; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 9eadd82..d881560 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -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'; diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts new file mode 100644 index 0000000..ca65437 --- /dev/null +++ b/packages/shared-types/src/ops.ts @@ -0,0 +1,15 @@ +export interface AdminListQuery { + page?: number; + pageSize?: number; +} + +export interface AdminPageResult { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export interface ExportRequest { + format?: 'csv'; +} diff --git a/packages/shared-types/src/redeem.ts b/packages/shared-types/src/redeem.ts new file mode 100644 index 0000000..6cdd55c --- /dev/null +++ b/packages/shared-types/src/redeem.ts @@ -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; +} diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts new file mode 100644 index 0000000..c766206 --- /dev/null +++ b/packages/shared-types/src/settlement.ts @@ -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; +} diff --git a/packages/shared-types/src/ticket.ts b/packages/shared-types/src/ticket.ts new file mode 100644 index 0000000..178925f --- /dev/null +++ b/packages/shared-types/src/ticket.ts @@ -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; +} diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts new file mode 100644 index 0000000..dda88f9 --- /dev/null +++ b/packages/shared-types/src/trade.ts @@ -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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b89e0dc..f8e22ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,20 @@ settings: importers: - .: {} + .: + devDependencies: + '@eslint/js': + specifier: ^9.17.0 + version: 9.39.4 + eslint: + specifier: ^9.17.0 + version: 9.39.4 + globals: + specifier: ^15.14.0 + version: 15.15.0 + typescript-eslint: + specifier: ^8.18.0 + version: 8.62.1(eslint@9.39.4)(typescript@5.9.3) apps/admin-web: dependencies: @@ -586,6 +599,64 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} @@ -1076,6 +1147,65 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript-eslint/eslint-plugin@8.62.1': + resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.1': + resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1152,6 +1282,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -1262,6 +1397,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1290,6 +1429,10 @@ packages: brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1536,6 +1679,9 @@ packages: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1661,15 +1807,53 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -1685,6 +1869,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -1715,12 +1903,24 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -1728,6 +1928,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@20.4.1: resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} engines: {node: '>=18'} @@ -1740,6 +1944,17 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1803,6 +2018,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -1811,6 +2030,14 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1859,10 +2086,22 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1974,6 +2213,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1983,6 +2225,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json2mq@0.2.0: resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} @@ -2013,6 +2258,13 @@ packages: jws@3.2.3: resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.7: resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==} @@ -2027,6 +2279,10 @@ packages: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2051,6 +2307,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} @@ -2139,6 +2398,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -2192,6 +2455,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -2257,6 +2523,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -2275,10 +2545,18 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-limit@5.0.0: resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} engines: {node: '>=18'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2294,6 +2572,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2339,6 +2621,10 @@ packages: resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2353,6 +2639,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2873,6 +3163,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} @@ -2964,6 +3258,10 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -3001,6 +3299,12 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -3026,6 +3330,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} @@ -3041,6 +3349,13 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.62.1: + resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.7.2: resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} engines: {node: '>=14.17'} @@ -3223,6 +3538,10 @@ packages: resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} engines: {node: '>=0.10.0'} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3261,6 +3580,10 @@ packages: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -3544,6 +3867,68 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@ioredis/commands@1.10.0': {} '@ioredis/commands@1.5.1': {} @@ -4051,6 +4436,97 @@ snapshots: '@types/validator@13.15.10': {} + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/type-utils': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) + '@typescript-eslint/types': 8.62.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.62.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.1': {} + + '@typescript-eslint/typescript-estree@8.62.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.9.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.1(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.43)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 @@ -4177,6 +4653,10 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-walk@8.3.5: dependencies: acorn: 8.17.0 @@ -4355,6 +4835,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.40: {} @@ -4395,6 +4877,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4626,6 +5112,8 @@ snapshots: dependencies: type-detect: 4.1.0 + deep-is@0.1.4: {} + deepmerge@4.3.1: {} default-user-agent@1.0.0: @@ -4744,13 +5232,75 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -4763,6 +5313,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + etag@1.8.1: {} events@3.3.0: {} @@ -4829,16 +5381,26 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} fast-uri@3.1.2: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fflate@0.8.3: {} figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@20.4.1: dependencies: '@tokenizer/inflate': 0.2.7 @@ -4864,6 +5426,18 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -4940,6 +5514,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} glob@10.4.5: @@ -4951,6 +5529,10 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + globals@14.0.0: {} + + globals@15.15.0: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -4993,11 +5575,17 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 + imurmurhash@0.1.4: {} + inherits@2.0.4: {} inquirer@8.2.6: @@ -5132,12 +5720,16 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json2mq@0.2.0: dependencies: string-convert: 0.2.1 @@ -5180,6 +5772,15 @@ snapshots: jwa: 1.4.2 safe-buffer: 5.2.1 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + libphonenumber-js@1.13.7: {} lines-and-columns@1.2.4: {} @@ -5191,6 +5792,10 @@ snapshots: mlly: 1.8.2 pkg-types: 1.3.1 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.defaults@4.2.0: {} lodash.includes@4.3.0: {} @@ -5207,6 +5812,8 @@ snapshots: lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} lodash@4.17.21: {} @@ -5272,6 +5879,10 @@ snapshots: mimic-fn@4.0.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.15 @@ -5337,6 +5948,8 @@ snapshots: nanoid@3.3.15: {} + natural-compare@1.4.0: {} + negotiator@0.6.3: {} neo-async@2.6.2: {} @@ -5386,6 +5999,15 @@ snapshots: dependencies: mimic-fn: 4.0.0 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -5409,10 +6031,18 @@ snapshots: dependencies: minimist: 1.2.8 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-limit@5.0.0: dependencies: yocto-queue: 1.2.2 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -5428,6 +6058,8 @@ snapshots: parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -5459,6 +6091,8 @@ snapshots: picomatch@4.0.1: {} + picomatch@4.0.5: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -5475,6 +6109,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -6128,6 +6764,8 @@ snapshots: strip-final-newline@3.0.0: {} + strip-json-comments@3.1.1: {} + strip-literal@2.1.1: dependencies: js-tokens: 9.0.1 @@ -6179,6 +6817,11 @@ snapshots: tinybench@2.9.0: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinypool@0.8.4: {} tinyspy@2.2.1: {} @@ -6207,6 +6850,10 @@ snapshots: tree-kill@1.2.2: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -6240,6 +6887,10 @@ snapshots: tslib@2.8.1: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-detect@4.1.0: {} type-fest@0.21.3: {} @@ -6251,6 +6902,17 @@ snapshots: typedarray@0.0.6: {} + typescript-eslint@8.62.1(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.1(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.7.2: {} typescript@5.9.3: {} @@ -6449,6 +7111,8 @@ snapshots: dependencies: semver: 5.7.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -6484,4 +7148,6 @@ snapshots: yn@3.1.1: {} + yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs new file mode 100644 index 0000000..fc1f3b8 --- /dev/null +++ b/scripts/smoke-v3.mjs @@ -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); +}); diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 9be31d0..2840f53 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -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", diff --git a/server/dukang-api/src/callbacks/callbacks.module.ts b/server/dukang-api/src/callbacks/callbacks.module.ts index c3b3883..7d3a179 100644 --- a/server/dukang-api/src/callbacks/callbacks.module.ts +++ b/server/dukang-api/src/callbacks/callbacks.module.ts @@ -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 {} diff --git a/server/dukang-api/src/callbacks/delivery-track.controller.ts b/server/dukang-api/src/callbacks/delivery-track.controller.ts new file mode 100644 index 0000000..1baf89d --- /dev/null +++ b/server/dukang-api/src/callbacks/delivery-track.controller.ts @@ -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 = { + 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); + } +} diff --git a/server/dukang-api/src/callbacks/wechat-refund.controller.ts b/server/dukang-api/src/callbacks/wechat-refund.controller.ts new file mode 100644 index 0000000..c5f13c4 --- /dev/null +++ b/server/dukang-api/src/callbacks/wechat-refund.controller.ts @@ -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, + @Res() res: Response, + ) { + try { + const body = typeof req.body === 'object' ? req.body : {}; + const outRefundNo = String((body as Record).out_refund_no ?? ''); + const refundId = String((body as Record).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 }); + } + } +} diff --git a/server/dukang-api/src/common/redis/redis.service.ts b/server/dukang-api/src/common/redis/redis.service.ts index c357d05..490b670 100644 --- a/server/dukang-api/src/common/redis/redis.service.ts +++ b/server/dukang-api/src/common/redis/redis.service.ts @@ -26,4 +26,8 @@ export class RedisService { async del(key: string) { await this.redis.del(key); } + + async ttl(key: string): Promise { + return this.redis.ttl(key); + } } diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts index caee503..0bb5c78 100644 --- a/server/dukang-api/src/modules/benefit/benefit.service.ts +++ b/server/dukang-api/src/modules/benefit/benefit.service.ts @@ -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: '退款作废权益', + }), + }); + } + }); + } + } } diff --git a/server/dukang-api/src/modules/catalog/catalog.controller.ts b/server/dukang-api/src/modules/catalog/catalog.controller.ts index 5117873..c927b7f 100644 --- a/server/dukang-api/src/modules/catalog/catalog.controller.ts +++ b/server/dukang-api/src/modules/catalog/catalog.controller.ts @@ -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') diff --git a/server/dukang-api/src/modules/catalog/catalog.mapper.ts b/server/dukang-api/src/modules/catalog/catalog.mapper.ts new file mode 100644 index 0000000..2c32a80 --- /dev/null +++ b/server/dukang-api/src/modules/catalog/catalog.mapper.ts @@ -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; + 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(); + for (const resource of resources) { + const key = resource.ownerId.toString(); + const list = map.get(key) ?? []; + list.push(resource); + map.set(key, list); + } + return map; +} diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index f60d56f..b0bda7f 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -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, }); } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index bae5713..c476a15 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -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 }, }); diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 55ba1c8..7481fe7 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -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, diff --git a/server/dukang-api/src/modules/ops/admin-stores.controller.ts b/server/dukang-api/src/modules/ops/admin-stores.controller.ts index e8ef1a5..259c930 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.controller.ts @@ -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') diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index ebb5e4a..3536cec 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -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 }, diff --git a/server/dukang-api/src/modules/ops/admin-tickets.controller.ts b/server/dukang-api/src/modules/ops/admin-tickets.controller.ts new file mode 100644 index 0000000..ab86cfb --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-tickets.controller.ts @@ -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); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-tickets.service.ts b/server/dukang-api/src/modules/ops/admin-tickets.service.ts new file mode 100644 index 0000000..f3bd330 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-tickets.service.ts @@ -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); + } +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index cb04e8e..5b5e0bd 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -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, ], }) diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index 12ea722..718d229 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -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); diff --git a/server/dukang-api/src/modules/redeem/redeem.module.ts b/server/dukang-api/src/modules/redeem/redeem.module.ts index 06557a7..282338b 100644 --- a/server/dukang-api/src/modules/redeem/redeem.module.ts +++ b/server/dukang-api/src/modules/redeem/redeem.module.ts @@ -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], diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 8a1b4ec..2871441 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -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(`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(`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 } }), ]); diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 01293e8..add2313 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -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) { + 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) { + 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) { + 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 { diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index ad5bc66..5638f10 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -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], }) diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 337f552..0049f19 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -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 }); + } } diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts index 4184268..f37ac6b 100644 --- a/server/dukang-api/src/modules/trade/trade.controller.ts +++ b/server/dukang-api/src/modules/trade/trade.controller.ts @@ -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); + } +} diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts index ec81939..4cf7be0 100644 --- a/server/dukang-api/src/modules/trade/trade.module.ts +++ b/server/dukang-api/src/modules/trade/trade.module.ts @@ -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], }) diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 7dfae8c..9a2bdd4 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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 }, diff --git a/杜康好客-V2编码手册.md b/杜康好客-V2编码手册.md index 622d8e1..4c1a884 100644 --- a/杜康好客-V2编码手册.md +++ b/杜康好客-V2编码手册.md @@ -1,5 +1,7 @@ # 杜康好客 · V2 编码手册(完整规格 · 唯一事实源) +> **V3 交付提示**:当前交付验收以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准。本手册中「核销单次上限 ¥500」等规则已被 V3 替代(直接核销可达总余额 / 单据 cap)。 + > **版本**:**V2**(完整四端 + 微信生态 + 真实第三方) > **联调裁剪版**:见 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)(三端 H5 + Mock,同库同 API 契约) > **用途**:V2 正式编码与 preV1 预留对齐的完整规格 diff --git a/杜康好客-preV1编码手册.md b/杜康好客-preV1编码手册.md index 9b62674..fff81dd 100644 --- a/杜康好客-preV1编码手册.md +++ b/杜康好客-preV1编码手册.md @@ -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 真实流程。