v3.5.1 版本更新
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-19 15:54:51 +08:00
parent 7dd5fdfb12
commit 233ed0af3b
103 changed files with 5764 additions and 185 deletions
@@ -0,0 +1,663 @@
# 杜康好客 v3.5.1 开发方案
> 9 个修改点 · 涉及 4 端(admin-web / h5-shop / mini-user / h5-partner+ 1 后端
> **落地对照(已实现)**:[`杜康好客-v3.5.1-开发文档.md`](./杜康好客-v3.5.1-开发文档.md)
> 本文为开工方案与探索记录;与实现不一致时以落地对照为准。
> 协作:模块边界仍按 AGENTS.md`store` 模块 OWNER 为 B+D**当前由 jacy-dukang 全权**,可直接写 `store_*`/`redeem` 表
---
## 0. 总体一览
| 编号 | 模块 | 改动范围 | 估时 |
|------|------|----------|------|
| #1 | 大屏(订单轮播+脱敏) | 后端新增 + admin-web 新页 + OrdersPage 加按钮 | 0.5d |
| #2 | 门店端核销后即时刷新 | h5-shop RedeemSuccess/Mine/Withdraw 三处 | 0.3d |
| #3 | 用户端"发票抬头"列表 | 后端新表新接口 + mini-user 新 2 页 + 我的页入口 | 1d |
| #4 | 用户端订单"申请发票" | 后端新接口 + mini-user 订单列表/详情按钮 | 0.5d |
| #5 | 合伙人端门店编辑改"提交变更" | 后端新表新接口 + h5-partner StoreDetailPage | 1d |
| #6 | "套餐审核" 改 "审核通知" | admin-web Layout + StorePackageAuditsPage 改造 | 0.3d |
| #7 | 门店列表 提现/对比 按钮(信息变更) | admin-web StoresPage + 后端 admin list 返回字段 | 0.3d |
| #8 | 订单 30 分钟未支付自动取消 | 后端新增定时任务(复用 `payExpireAt` + 现有取消写法) | 0.5d |
| #9 | 城市合伙人/门店接口强校验 status+bindstatus,否则强制退登录 | 后端 `JwtAuthGuard` 注入状态校验 + `HttpExceptionFilter` 透传 + 两端前端跳登录 | 0.5d |
跨模块串联点:**#5 + #6 + #7** 共享同一个 `StoreInfoChangeRequest` 模型与 `AUDIT_NOTICE_CHANGED_EVENT`**#9** 串联后端守卫 + 前端两端 `api.ts` 的退出逻辑。
> **#8 / #9 补充说明**
> - #8:下单时 `trade.service.ts` 已写 `payExpireAt = now + 30min`line 206/1590/1935),支付/查询也已校验过期(line 2056/2145),但**缺少把 DB 状态翻成 `CANCELLED` 的定时任务**,本次补上。
> - #9:需求里的"门店 status + bindstatus"在模型上对应 **StoreAccount.status(门店账号启停,须 `ACTIVE`+ Store.status(门店启停,须 `OPEN`**"合伙人 status + bindstatus"对应 **PartnerAccount.status(须 `ACTIVE`+ PartnerAccount.bindingStatus(须 `ACTIVE`)**。两端不共用同一模型,校验需分别查。详见 §10。
---
## 1. 关键决策(已与用户对齐)
| 决策点 | 选择 |
|--------|------|
| 审核中心合并策略 | 合并套餐审核 + 信息变更 → 统一进「审核通知」 |
| 大屏实时性 | 3 秒 polling(无 SSE/WS |
| 审核通过生效方式 | 直接覆盖 `store` 表 + 审核中隐藏(前端展示 liveSnapshot |
| 申请发票范围 | 仅 `COMPLETED` 订单 |
---
## 2. 数据模型新增(共 2 张表)
### 2.1 `UserInvoiceTitle`(发票抬头)
```prisma
model UserInvoiceTitle {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
titleType InvoiceTitleType @map("title_type") // PERSONAL | ENTERPRISE
titleName String @map("title_name") @db.VarChar(128)
taxNo String? @map("tax_no") @db.VarChar(32)
email String? @db.VarChar(128)
phone String? @db.VarChar(20)
addressPhone String? @map("address_phone") @db.VarChar(256)
bankAccount String? @map("bank_account") @db.VarChar(256)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
invoices UserInvoice[] // 反向关联,已有
@@index([userId, isDefault])
@@map("user_invoice_title")
}
```
> 同时在 `model User { ... }` 增加 `invoiceTitles UserInvoiceTitle[]`
### 2.2 `StoreInfoChangeRequest`(门店信息变更请求)
```prisma
enum StoreInfoChangeStatus { PENDING APPROVED REJECTED }
enum StoreInfoChangeSubmitterType { PARTNER SHOP HQ_DIRECT_ADMIN }
model StoreInfoChangeRequest {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
status StoreInfoChangeStatus @default(PENDING)
/// liveSnapshot = 变更前 Store 全量快照;proposedSnapshot = 提单时希望变更的字段集合
liveSnapshot Json @map("live_snapshot")
proposedSnapshot Json @map("proposed_snapshot")
/// 可变字段白名单:name/contactPhone/address/intro/benefitUsageRule/coverUrl/envPhotoUrls/openTime/closeTime/openTime2/closeTime2/avgPrice
changedFields Json @map("changed_fields")
submitterType StoreInfoChangeSubmitterType @map("submitter_type")
submitterId BigInt @map("submitter_id") @db.UnsignedBigInt
rejectReason String? @map("reject_reason") @db.VarChar(512)
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@index([storeId, status])
@@index([status, createdAt])
@@map("store_info_change_request")
}
```
> 同时在 `model Store { ... }` 增加 `infoChangeRequests StoreInfoChangeRequest[]`
**Prisma 迁移策略**
- 修改 `server/dukang-api/prisma/schema.prisma`
- 执行 `pnpm db:generate``cd server/dukang-api && npx prisma db push`
- 重启 `pnpm dev:api`
---
## 3. 后端新增/修改清单
### 3.1 新建 `modules/store/store-info-change.{controller,service}.ts`
| 路由 | 方法 | 角色 | 说明 |
|------|------|------|------|
| `POST /partner/stores/:storeId/info-change-request` | body: `{ fields: Partial<StoreInput> }` | PARTNER | 提交基本资料变更 |
| `GET /partner/stores/:storeId/info-change-requests` | — | PARTNER | 看本门店历史变更 |
| `POST /shop/store/info-change-request` | 同上 | SHOP | 门店端提交变更 |
| `GET /admin/store-info-change-requests?status=&page=` | — | HQ | 列表 |
| `GET /admin/store-info-change-requests/summary` | — | HQ | 返回 `pendingCount`(与套餐审核汇总汇总求和) |
| `GET /admin/store-info-change-requests/:id` | — | HQ | 详情(live + proposed diff |
| `PUT /admin/store-info-change-requests/:id/audit` | `{ action, rejectReason? }` | HQ | 通过/驳回 |
服务函数(`StoreInfoChangeService`):
```ts
submit({ storeId, actorId, actorType, fields }) {
// 1) 校验 store 归属
// 2) 拉 live = prisma.store.findUniquediff 出 changedFields
// 3) prisma.storeInfoChangeRequest.createlive/proposed JSON
// 4) 同 store 已有 PENDING 时直接替换(最新优先)
// 5) 触发 AUDIT_NOTICE_CHANGED_EVENT
}
audit({ id, action, reason, reviewerId }) {
if (action === APPROVED) {
// 把 proposedSnapshot 字段写到 store(白名单)
// store 通过 storeService.updateByHq()(如无此方法,需新建;不能直接 prisma 改 store
// store 信息变更后,partner 端 Strip Pending
}
// 写状态/驳回原因
// 触发 AUDIT_NOTICE_CHANGED_EVENT
}
```
---
### 3.2 新建 `modules/trade/invoice-title.{controller,service}.ts`
| 路由 | 方法 | 说明 |
|------|------|------|
| `GET /trade/invoice-titles` | — | 当前用户的抬头列表 |
| `POST /trade/invoice-titles` | body | 新建 |
| `PUT /trade/invoice-titles/:id` | body | 编辑;可同时 `isDefault=true` 触发其他置 false |
| `DELETE /trade/invoice-titles/:id` | — | 删除(已被 UserInvoice 引用的禁止删除,返回 409) |
| `GET /trade/orders/:orderId/invoice-titles` | — | 订单所有可用的抬头(用户的 + 默认) |
> 利用已有 `UserInvoice` 表;`UserInvoice.titleId` 可选填空(也可保留 `titleName/taxNo` 冗余快照——**建议保留**:每张发票保存当时的抬头快照,避免抬头被删后历史记录走样)
---
### 3.3 修改 `modules/trade/trade.controller.ts` — 新增(订单可用发票接口)
| 路由 | 方法 | 说明 |
|------|------|------|
| `GET /trade/orders/:orderId/invoice-status` | — | 返回当前订单是否已开票(`EXISTS \| NONE` |
| `GET /trade/invoices` | — | 我的发票申请列表 |
服务函数基于 `UserInvoice` 现有查询扩展。
---
### 3.4 修改 `modules/trade/admin-big-screen.controller.ts`**新建**
| 路由 | 方法 | 说明 |
|------|------|------|
| `GET /admin/orders/big-screen?limit=20` | — | 返回最近 N 条订单:订单号、金额、商品名/规格/数量、下单人 phone(**强制脱敏**)、下单时间 |
关键实现:
```ts
const orders = await prisma.order.findMany({
orderBy: { createdAt: 'desc' },
take: Math.min(Number(limit) || 20, 50),
include: { items: true, user: { select: { phone: true } } }
});
return orders.map(o => ({
id: o.id, orderNo: o.orderNo, payAmount: o.payAmount,
createdAt: o.createdAt,
userPhoneMasked: o.user?.phone ? maskPhone(o.user.phone) : null,
items: o.items.map(it => `${it.productName} × ${it.quantity}`).join(''),
}));
```
> 复用于 `packages/domain` 中已存在的 `maskPhone` 函数。新建 admin-big-screen.controller.ts 即可,不动 trade.controller.ts。
---
### 3.5 修改 `modules/store/store.controller.ts` 或 trade ops 模块
- 现有 `/partner/stores/:storeId/basic` (PUT) 是**直接生效**的,需保留为 backward-compat**新增** `/partner/stores/:storeId/info-change-request`(见 3.1
- 现有门店端 `/shop/store` (GET) 不变;本次不修改
---
### 3.6 shared-types 包
`packages/shared-types/src/invoice-title.ts` 新增:
```ts
export interface UserInvoiceTitleDto {
id: string; titleType: InvoiceTitleType; titleName: string;
taxNo?: string; email?: string; phone?: string;
addressPhone?: string; bankAccount?: string; isDefault: boolean;
}
export const INVOICE_CHANGEABLE_FIELDS = [
'titleName', 'taxNo', 'email', 'phone', 'addressPhone', 'bankAccount', 'isDefault'
] as const;
```
`packages/shared-types/src/store-info-change.ts` 新增:
```ts
export type StoreInfoChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
export type StoreInfoChangeSubmitterType = 'PARTNER' | 'SHOP' | 'HQ_DIRECT_ADMIN';
export interface StoreInfoChangeRequestDto { ... }
export interface StoreInfoChangeSummaryDto { pendingCount: number; }
export const STORE_INFO_CHANGEABLE_FIELDS = [
'name', 'contactPhone', 'address', 'intro', 'benefitUsageRule',
'coverUrl', 'envPhotoUrls', 'openTime', 'closeTime',
'openTime2', 'closeTime2', 'avgPrice'
] as const;
```
---
### 3.7 shared-events 全局事件
新增 `packages/shared-events/src/admin-events.ts` 或在 admin-web 的 `lib/admin-events.ts` 增加:
```ts
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
export const STORE_REDEEM_SUCCESS_EVENT = 'dukang:shop-redeem-success';
```
---
### 3.8 新增 `jobs/order-expiry.scheduler.ts`#8 自动取消过期订单)
> 关键事实:下单时 `trade.service.ts` 已写 `payExpireAt = now + 30min`,支付/查询也已校验过期,但**没有定时任务**真正把 `status` 翻成 `CANCELLED`。后端 `src/jobs/` 已用 `@Cron``@nestjs/schedule`)范式(`settlement.scheduler.ts`),`ScheduleModule` 已在 `jobs.module.ts` 注册。
**新建 `server/dukang-api/src/jobs/order-expiry.scheduler.ts`**
```ts
@Injectable()
export class OrderExpiryScheduler {
private readonly logger = new Logger(OrderExpiryScheduler.name);
constructor(private readonly trade: TradeService) {}
// 每分钟扫描一次;订单量不大,1 分钟粒度足够"30 分钟"语义
@Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' })
async handleExpiredPendingOrders() {
try {
const n = await this.trade.cancelExpiredPendingOrders(200);
if (n > 0) this.logger.log(`Auto-cancelled ${n} expired pending orders`);
} catch (e) {
this.logger.error('Order expiry job failed', e instanceof Error ? e.stack : e);
}
}
}
```
**`TradeService.cancelExpiredPendingOrders(limit = 200)`(新增方法)**
```ts
async cancelExpiredPendingOrders(limit = 200): Promise<number> {
const expired = await this.prisma.order.findMany({
where: {
status: 'PENDING_PAY',
payStatus: 'UNPAID',
payExpireAt: { lt: new Date() },
isTest: false, // 跳过测试订单,避免污染数据
},
take: limit,
select: { id: true },
});
for (const o of expired) {
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: o.id },
data: { status: 'CANCELLED', cancelledAt: new Date() },
});
await tx.commonEvent.create({
data: buildOrderStatusEvent({ // 复用现有导入(见 line 1810
orderId: o.id, fromStatus: 'PENDING_PAY', toStatus: 'CANCELLED',
operator: 'SYSTEM_AUTO_EXPIRE', remark: '30 分钟未支付自动取消',
}),
});
});
}
return expired.length;
}
```
**注册**`server/dukang-api/src/jobs/jobs.module.ts``providers` 增加 `OrderExpiryScheduler``TradeModule` 已 import,无需改 imports)。
> **待确认**:取消时是否需回滚已占用的 `benefitCoupon`?现有 `cancelPartnerProxyOrder`line 1791)仅翻状态、未回滚权益券。若业务要求自动取消也释放权益,此处需补 `benefitCoupon.update({ status: 'ACTIVE' })`。
---
### 3.9 修改 `JwtAuthGuard` + `HttpExceptionFilter`#9 强校验,详见 §10
> 完整探查见 §10。要点:
- **`server/dukang-api/src/common/guards/jwt-auth.guard.ts`**
- `canActivate` 改为 `async`;注入 `PrismaService`
-`req.user` 组装完成后,按 `actorType` 分支(**仅 STORE / PARTNERUSER 跳过**):
- `STORE`:查 `storeAccount(actorId)``status``ACTIVE`;若 `req.user.storeId` 存在,查 `Store``status``OPEN`
- `PARTNER`:查 `partnerAccount(actorId)``status === 'ACTIVE'``bindingStatus === 'ACTIVE'`
- 不通过 → `throw new ForbiddenException({ reason: 'ACCOUNT_DISABLED', message: '账号已停用或解绑,请重新登录' })`
- 覆盖率 100%`/shop/*``/partner/*` 全部经过 `JwtAuthGuard`(含仅用 `JwtAuthGuard` 的合伙人路由);登录/refresh 走 `OptionalJwtAuthGuard`,不受影响。
- **`server/dukang-api/src/common/filters/http-exception.filter.ts`**line 58-60 的 `response.status(status).json({...})`):
- 增加透传 `reason` 字段:`reason: (res && typeof res === 'object' && (res as any).reason) ?? null`,使前端能精确识别"账号停用"。
---
## 4. 前端新增/修改清单
### 4.1 admin-web(管理后台)
| # | 文件 | 改动 |
|---|------|------|
| 1 | `src/pages/OrdersPage.tsx` | 头部加 `<Button>` "大屏" → `window.open('/orders/big-screen?token=xxx','_blank')` |
| 2 | `src/pages/BigScreenPage.tsx`(**新建**) | 暗色科技风,"杜康好客 · 发布仪式现场" + LIVE 标识 + 大时钟 + 订单表格轮播;3s polling |
| 3 | `src/App.tsx` | 加路由 `<Route path="/orders/big-screen" element={<BigScreenPage />} />` |
| 4 | `src/layouts/AdminLayout.tsx` | 菜单项 label `'套餐审核'``'审核通知'``attachPackageAuditBadge` 改名为 `attachAuditNoticeBadge`,订阅 `AUDIT_NOTICE_CHANGED_EVENT` + 调 `summary` 端点(含合并计数) |
| 5 | `src/pages/StorePackageAuditsPage.tsx` | 标题 `套餐变更审核``审核通知`;加 `<Tabs>`:套餐变更 / 门店信息变更;下方按 `kind` 调不同接口;详情抽屉兼容两类 |
| 6 | `src/pages/StoresPage.tsx` | 列表中加 `pendingInfoChangeId` 字段;操作列在已有 `pendingPackageAuditId` 旁补 `审核信息 / 对比`;提现按钮(指向 `/finance/store-bills?storeId=`)已存在,本次仅调整文案 |
> BigScreenPage 不依赖 AdminLayout(避免侧边栏干扰),可在路由外层做独立壳层。
---
### 4.2 h5-shop(门店端)
| # | 文件 | 改动 |
|---|------|------|
| 1 | `src/pages/RedeemSuccessPage.tsx` | 提交成功 onMounted 派发 `window.dispatchEvent(new CustomEvent(STORE_REDEEM_SUCCESS_EVENT, { detail: { storeId, amount }}))`;在 navigate(-1) 之前 |
| 2 | `src/pages/MinePage.tsx` | 监听 `STORE_REDEEM_SUCCESS_EVENT` → 重跑 `loadMine()`(已存在) |
| 3 | `src/pages/WithdrawPage.tsx` | 同上监听 → 重跑 `load()` |
| 4 | `src/lib/useRedeemSuccessBus.ts`**新建** | 简单 hook`useRedeemSuccessListener(handler)` 复用 |
---
### 4.3 mini-user(用户小程序)
| # | 文件 | 改动 |
|---|------|------|
| 1 | `src/pages/mine/index.tsx` | SERVICES 数组加一项 `{ icon: iconInvoice, label: '发票管理', url: '/pages/invoice-titles/index' }` |
| 2 | `src/pages/invoice-titles/index.tsx`(**新建**) | 列表(默认抬头星标 / 编辑 / 删除 / 设为默认)+ 进入"申请发票"快捷入口 |
| 3 | `src/pages/invoice-title-edit/index.tsx`(**新建**) | 编辑/新建抬头表单 |
| 4 | `src/pages/orders/index.tsx` | 仅 COMPLETED 订单下方加 `<View className="order-invoice-btn">申请发票</View>` → 跳发票申请页 |
| 5 | `src/pages/order-detail/index.tsx` | 订单状态 COMPLETED 时,详情页 actionbar 增加"申请发票"按钮 |
| 6 | `src/pages/invoice-apply/index.tsx`**新建** | 复用 SubPageHeader 组件;展示该订单明细 + 已开票则提示已存在 + 抬头选择器 + 提交 |
| 7 | `src/app.config.ts` | pages 数组增加 3 个新页(`invoice-titles/index``invoice-title-edit/index``invoice-apply/index` |
| 8 | `src/lib/api.ts` | 增加 `requestInvoiceTitles()``submitInvoiceApplication()` 等 helper |
---
### 4.4 h5-partner(合伙人端)
| # | 文件 | 改动 |
|---|------|------|
| 1 | `src/pages/StoreDetailPage.tsx` | 底部保存按钮 `保存修改``提交变更`;点击后调 `PUT /partner/stores/:id/info-change-request` 而不是 `/basic` |
| 2 | 同上 | 顶部加一个 `auditBanner`:当 `store.infoChangePending === true` 时提示"有变更待总部审核"(调 list 接口得到 PENDING |
| 3 | `src/lib/api.ts` | 加 `submitStoreInfoChangeRequest``listStoreInfoChangeRequests` |
> **兼容老路径**:保留 `/partner/stores/:id/basic`PUT)以兼容历史脚本;新按钮走新接口。
---
### 4.5 h5-shop / h5-partner 强制退出登录(#9,详 §10)
> 后端返回 `{ code: 403, reason: 'ACCOUNT_DISABLED', message }` 后,前端需 `clearAuth()` + 跳 `/login`。合伙人端现仅 401 跳登录、门店端缺此逻辑,两端都要补。
| # | 文件 | 改动 |
|---|------|------|
| 1 | `apps/h5-shop/src/lib/api.ts` | `rawRequest`~191-227)抛错时附加 `err.reason = json.reason ?? null``requestWithAuthRetry` 的不可恢复分支(~262 前)与 `ensureSession` catch~305)增加 `if (err.reason === 'ACCOUNT_DISABLED') { clearAuth(); window.location.href = '/login'; }` |
| 2 | `apps/h5-partner/src/lib/api.ts` | `rawRequest` 同样附加 `err.reason`;在现有 401 重定向分支旁增加 `reason === 'ACCOUNT_DISABLED'``clearAuth() + window.location.href = toAppPath('/login')`(复用既有登录路由) |
> 注意:`ACCOUNT_DISABLED` 用专属 `reason` 字段而非复用 403,避免与未来其它 403(权限不足)混淆;`code` 仍为 403 不破坏现有 `err.status` 判断。
---
## 5. 大屏页面设计要点(#1
```tsx
// BigScreenPage.tsx 骨架(admin-web 不依赖 AntD
export default function BigScreenPage() {
const [items, setItems] = useState<BigScreenOrder[]>([]);
useEffect(() => {
const fetchData = () => request<{ items: BigScreenOrder[] }>(
'/admin/orders/big-screen?limit=20'
).then(d => setItems(d.items));
fetchData();
const id = setInterval(fetchData, 3000);
return () => clearInterval(id);
}, []);
return (
<div className="big-screen-page"> {/* 全屏暗色 */}
<header>
<span className="title"></span>
<span className="subtitle"></span>
<span className="live"> LIVE</span>
</header>
<Clock /> {/* setInterval(每秒) */}
<table>
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{items.map(o => (
<tr key={o.id}>
<td>¥{o.payAmount.toLocaleString()}</td>
<td>{o.items}</td>
<td>{formatHM(o.createdAt)}</td>
<td>{o.userPhoneMasked}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
```
CSS 暗色 + 蓝色辉光 + 全屏(`h1 字号 96px、background #001529`),仿参考图。
---
## 6. 关键交互流程图
### 6.1 #1 大屏实时轮播
```
[admin BigScreenPage mounted]
request GET /admin/orders/big-screen?limit=20
[Backend] findMany Order desc createdAt, take 20, mask phone
setInterval(3000) → 重复
```
### 6.2 #2 门店端核销即时刷新
```
[RedeemSuccessPage] onMount → window.dispatchEvent(STORE_REDEEM_SUCCESS_EVENT)
[MinePage useEffect] 收到 → setLoading → request /shop/store
[WithdrawPage useEffect] 收到 → request /shop/withdraw/summary
```
### 6.3 #5/#6/#7 审核通知闭环
```
[Partner StoreDetailPage] 点击"提交变更"
PUT /partner/stores/:id/info-change-request {fields}
[Backend] 写入 StoreInfoChangeRequest + 触发 AUDIT_NOTICE_CHANGED_EVENT
[StoreDetailPage] 显示"有变更待总部审核"banner
[admin StorePackageAuditsPage] 进入 → 切到「门店信息变更」Tab → 列表
[admin 点"通过"] → PUT .../audit {action:APPROVE}
[Backend] proposedSnapshot 写入 store → 触发 AUDIT_NOTICE_CHANGED_EVENT
[StoreDetailPage] banner 消失
```
---
## 7. 验证 DoD
- [ ] `pnpm db:generate && db:validate && npx prisma db push` 无报错
- [ ] 现有 admin/stores 接口能取到 `pendingInfoChangeId` 字段;旧调用方不报错
- [ ] 订单「申请发票」按钮仅在 `COMPLETED` 时出现;提交后调用 `/trade/orders/:id/invoices`
- [ ] 门店端在 A 设备核销 → B 设备 MinePage(如多 Tab)≤3s 看到变化(A 设备本身立即)
- [ ] 大屏 3s 内出现新单;手机号中间 4 位为 `****`
- [ ] 合伙人提交门店变更后,门店 C 端/合伙人端展示 liveSnapshot(不立即生效),admin 端能审核通过/驳回
- [ ] 审核通知 badge = 套餐变更 PENDING + 信息变更 PENDING
- [ ] #8:构造 `payExpireAt` 已过期的待支付订单,≤1 分钟内状态自动变 `CANCELLED``cancelledAt` 写入、状态日志 `SYSTEM_AUTO_EXPIRE` 存在
- [ ] #9:将某门店 `StoreAccount.status` 置非 `ACTIVE`(或 `Store.status``OPEN`),其门店端任意接口返回 `reason: ACCOUNT_DISABLED`,前端立即 `clearAuth()` 并跳 `/login`
- [ ] #9:将某合伙人 `bindingStatus` 置非 `ACTIVE`,合伙人端任意接口触发强退登录;`mini-user`USER actor)不受影响
- [ ] `pnpm lint && pnpm test` 无新增错误
---
## 8. 任务拆分(建议 DLV 命名)
```
DLV-W1-M1 #1 大屏(后端 + admin-web 新页)
DLV-W1-M2 #2 门店端核销事件总线
DLV-W1-M3 #6 审核通知菜单改 + Tabs 改造(先做,让 #5/#7 共享 UI)
DLV-W1-M4 #5 合伙人端门店"提交变更"StoreInfoChangeRequest 全套)
DLV-W1-M5 #7 门店列表 提现/对比 按钮(信息变更)
DLV-W1-M6 #3 发票抬头 mini-user 列表 + 编辑
DLV-W1-M7 #4 订单申请发票(依赖 M6)
DLV-W1-M8 #8 订单 30 分钟未支付自动取消(后端定时任务,独立)
DLV-W1-M9 #9 城市合伙人/门店接口强校验 + 前端强制退登录
```
依赖关系:M1 独立;M2 独立;M3 无依赖;M4 / M5 依赖 M3M6 无依赖;M7 依赖 M6;M8 独立;M9 独立(后端守卫 + 前端两端,互不阻塞)。
---
## 9. 待确认的设计细节(如需开工前敲定)
1. 发票抬头列表页是否需要"设为默认"按钮?
2. 门店信息变更驳回后,原表单是否需要提示驳回原因?
3. 大屏是否需要支持"暂停轮播"按钮?
4. 用户端 `My → 发票管理` 是否需要再放"申请记录"二级入口?
5. **#8 权益回滚**:自动取消订单时是否需要把已占用的 `benefitCoupon` 置回 `ACTIVE`?(现有手动取消 `cancelPartnerProxyOrder` 未回滚,建议保持一致或明确要回滚)
6. **#8 扫描粒度**:每分钟一次(`*/1 * * * *`)是否可接受?还是希望 2~5 分钟一次?
7. **#9 校验范围**:需求只提"城市合伙人 + 门店"。是否也要覆盖 `h5-shop` 的**城市合伙人代运营账号**`proxyPartnerAccountId` 场景)?目前按"仅 STORE + PARTNER actor"实现。
8. **#9 提示文案**:强退登录时前端是否需要 toast 提示"账号已停用/解绑,请重新登录"再跳登录页,还是静默跳回?
9. **#9 测试订单**`isTest` 门店/合伙人账号是否要跳过强校验(避免测试环境自锁)?
---
## 10. 状态校验能力探索报告(城市合伙人 / 门店 接口强制退出到登录页)
> 本需求:所有 `/shop/*` 与 `/partner/*` 接口需校验 status / bindstatus 是否为开启,否则强制前端退到登录页。以下是按代码实际探查的结果与落地建议。
### 10.1 模型字段(server/dukang-api/prisma/schema.prisma
- **Store**line 1245):只有 `status StoreStatus @default(PAUSED)`line 1268),**没有 bindstatus 字段**。
- `enum StoreStatus`line 282):`OPEN / PAUSED / CLOSED`。"开启" = `OPEN`
- **PartnerAccount**line 1030):`status AccountStatus @default(ACTIVE)`line 1042+ `bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")`line 1049)。
- `enum AccountStatus`line 244):`ACTIVE / DISABLED`
- `enum CityPartnerStatus`line 203):`ACTIVE / PAUSED`。"已绑定/开启" = `ACTIVE`
- **StoreAccount**(门店登录账号,line 1365):`status AccountStatus @default(ACTIVE)`(line 1378)——这是门店端真正的"停用开关",登录时已被校验(`auth.service.ts:1050` `if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用')`)。
- 结论:需求中的"门店 status + bindstatus"在模型上对应 **StoreAccount.status(账号启停)+ Store.status(门店启停 OPEN**"合伙人 status + bindstatus"对应 **PartnerAccount.status + PartnerAccount.bindingStatus**。两端不共用同一模型。
### 10.2 后端鉴权守卫(server/dukang-api/src/common/guards/
- 全局错误格式 `http-exception.filter.ts:58-62``{ code: status, message, data: null }`;成功 `response.interceptor.ts:13-17``{ code:0, message:'ok', data }`。注册于 `main.ts:87-88`
- **JwtAuthGuard**jwt-auth.guard.ts:21-56):校验 Bearer + `x-client-app` + actorType,写入 `req.user: AuthUser{actorType, actorId, clientApp, storeId?}`。**无 DB 查询、无 status 校验**。
- 路由守卫分布:
- `/shop/*` 统一 `@UseGuards(JwtAuthGuard, ShopStoreGuard)`(如 store.controller.ts:186-187、settlement.controller.ts:48-49、redeem.controller.ts:54-55)。
- `/partner/*` 部分 `@UseGuards(JwtAuthGuard, PartnerPrimaryGuard/PartnerPermissionGuard)`,也有**仅** `@UseGuards(JwtAuthGuard)` 的(settlement.controller.ts:573 `partner/me`、store.controller.ts:75/86、store-package.controller.ts:8)。
- 现有守卫**均未校验 status/bindstatus**(运行时):
- `ShopStoreGuard`shop-store.guard.ts:1-15):仅校验 `user.storeId` 存在。
- `PartnerPrimaryGuard`partner-primary.guard.ts:11-32):查 `partnerAccount` + 校验 `isPrimary==1`
- `PartnerPermissionGuard`partner-permission.guard.ts:14-45):查 `partnerAccount` + 校验权限。
- `ShopPrimaryGuard`shop-primary.guard.ts):查 `storeAccount.isPrimary`
- **关键缺口**:登录后账号被停用/解绑,运行时接口不拦截(仅登录时校验过 StoreAccount.status)。
### 10.3 前端登录态与退出
- **h5-shop/src/lib/api.ts**token 存 localStorageACCESS_TOKEN/REFRESH_TOKEN),profile 存 STORE_PROFILE`clearAuth`136-145)清 token。
- `rawRequest`191-227):`json.code !== 0` 抛 Error`err.status = (>=500?res.status:json.code)`401→401403→403)。
- `requestWithAuthRetry`249-270):仅 `err.status===401` 且非豁免路径时尝试 refresh;失败则 `clearAuth()` 并 throw。**shop 端 request 没有跳登录页逻辑**。
- 登录路由 `/login`AuthGate.tsx:5、多处 `navigate('/login')`)。
- **h5-partner/src/lib/api.ts**:结构同构;`request`217-242)在 `err.status===401` 且本地有 token 时 `clearAuth()` + `showPartnerToast` + `window.location.href = toAppPath('/login')`(不在 /login 时)。**合伙人端已具备"401 强制跳登录"**。
- 登录路由 `toAppPath('/login')`
- 结论:partner 端天然可接 401 强退;shop 端缺这一步。
### 10.4 请求封装
- 两端共用 fetch 封装(rawRequest / requestWithAuthRetry / request),无 axios。业务 code 与 HTTP 码复用同一 `code` 字段(filter 把 HTTP status 当 code)。
- `requestWithAuthRetry` 只对 401 重试,**对 403/自定义 code 直接 throw → 不会触发 refresh 死循环**(这是设计专属 code 的基础)。
### 10.5 落地建议
**后端**:在 `JwtAuthGuard.canActivate` 内(或新建 `AccountActiveGuard` 全局)按 actorType 分支做 DB 校验:
- `STORE`:查 `StoreAccount(actorId)``status` 必须 `ACTIVE`;若 `user.storeId` 存在,查 `Store``status` 必须 `OPEN`
- `PARTNER`:查 `PartnerAccount(actorId)``status===ACTIVE``bindingStatus===ACTIVE`
-**`ForbiddenException` 携带专属业务 code(如 `ACCOUNT_DISABLED = 499`)** 而非 401,避免误触前端 refresh 重试。需扩展 `HttpExceptionFilter` 透传该 bizCode。
- 选 JwtAuthGuard 内部做可保证 100% 覆盖(所有 /shop、/partner 都过它,含仅用 JwtAuthGuard 的 partner/me);`USER` actorType 跳过,不影响 C 端/小程序。
**前端**:当 `json.code === ACCOUNT_DISABLED` 时,h5-partner 与 h5-shop 均 `clearAuth()` + 跳 `/login`partner 已在 401 分支,shop 需补 redirect)。若不想改 filter,可复用 403:但 partner 现只对 401 跳、对 403 不跳,故**专属 code 最稳**。
### 10.6 风险点
- **性能**:每个 /shop、/partner 请求多 1 次 DB 查询;用 `select` 仅取 status 字段即可,流量低可接受。
- **登录流程不受影响**`/shop/auth/login``/shop/auth/token/refresh` 等用 OptionalJwtAuthGuard 或无 guard,不会被拦;但 `/shop/auth/me``select-store` 用 JwtAuthGuard,停用账号访问会被拦→前端跳登录(符合预期)。
- **范围**HQ/admin 走 HqAuthGuard、mini-user 为 USER actor,均不受影响(按需求只做 shop + partner)。
- **门店校验建议同时覆盖 StoreAccount.status 与 Store.status**,否则只停门店不停账号仍可操作。
---
## 11. 订单 30 分钟未支付自动取消 — 代码探索报告
> 本报告为 v3.5.1 功能「订单 30 分钟未支付自动改为取消」的实现前探索,供开发方案设计参考。
## 1. 订单状态枚举
- Prisma 定义 `server/dukang-api/prisma/schema.prisma:321-331`
```prisma
enum OrderStatus {
PENDING_PAY // 待付款
PENDING_SHIP
OUT_WAREHOUSE
SHIPPING
PENDING_RECEIVE
COMPLETED
CANCELLED // 已取消
REFUNDING
REFUNDED
}
```
- TS 同步定义 `packages/shared-types/src/enums.ts:71-81`,字符串值完全一致:
`PENDING_PAY = 'PENDING_PAY'``CANCELLED = 'CANCELLED'`。
- 待付款 = `PENDING_PAY`;已取消 = `CANCELLED`(无 CLOSED / UNPAID 形态,PayStatus 才是 UNPAID)。
## 2. 订单创建流程
- `TradeService.createOrder` `src/modules/trade/trade.service.ts:153`;初始 `status: 'PENDING_PAY'`:232)、`payStatus: 'UNPAID'`:233)。
- 已写入支付截止时间:`const payExpireAt = new Date(Date.now() + 30 * 60 * 1000)`:206),并落库 `payExpireAt`:264)。
- 三条建单路径均已设置该字段:`createOrder`:206)、`createPartnerProxyOrder`:1590)、`createHqProxyOrder`:1935)。
- 下单时间 `createdAt``schema.prisma:1400/1473`);支付时间 `paidAt`:1455);取消时间 `cancelledAt`(:1458,已存在可复用,无需新增字段)。
## 3. 现有定时任务机制
- 已启用 `@nestjs/schedule``src/jobs/jobs.module.ts:13` `ScheduleModule.forRoot()`,并注册 BullMQ `DELIVERY_QUEUE``jobs.constants.ts`,用于 `delivery.processor.ts`)。
- `@Cron` 完整示例 `src/jobs/settlement.scheduler.ts`
```ts
@Cron('0 8 * * *', { timeZone: 'Asia/Shanghai' })
async handleDailyBills() {
try { /* ... */ }
catch (e) { this.alert.notify({ level: 'P0', category: 'job', title: '...', dedupeKey: '...' }); }
}
```
- **关键缺口**`src/jobs/monitor.scheduler.ts:54` `scanStuckOrders()` 已每 5 分钟(`*/5 * * * *`)扫描超时未付款订单,但**只 alert 不取消**(:61-81)。这正是自动取消要补的环节。
- `payExpireAt` 字段已存在,**无需新增迁移**;但 Order 表无 `(status, payExpireAt)` 复合索引(现有索引见 `schema.prisma:1488-1497`)。
## 4. 取消订单的业务影响
- 现有取消入口仅 `cancelPartnerProxyOrder`:1791,限 PROXY 单):事务内 `update status: 'CANCELLED', cancelledAt: new Date()`:1807+ 写 `commonEvent` 状态日志(:1809)。
- 通用状态机 `applyStatusTransition`:1324):翻 `order.status` + 写 `commonEvent``buildOrderStatusEvent`),但**不处理 `cancelledAt` / `payStatus`**。
- grep 确认建单与取消均**无库存占用/权益发放**(无 stock/inventory/decrement/release 调用,仅 `benefitCoupon` 出现在查询 include)。→ 自动取消只需翻状态+写日志,**无需回滚库存/释放权益**。
- 建议:新增 `TradeService.autoCancelExpiredOrders()`,复用 `applyStatusTransition` 但补 `cancelledAt` 写入(或在内联事务中同时置 `status/ cancelledAt/ payStatus`)。
## 5. 查询未支付订单
- 现成写法(`monitor.scheduler.ts:61`):
```ts
this.prisma.order.findMany({
where: { status: 'PENDING_PAY', payExpireAt: { lt: now } },
select: { orderNo: true }, take: sample, orderBy: { payExpireAt: 'asc' },
});
```
- 建议加 `payStatus: 'UNPAID'` 过滤更稳妥;字段即 `payExpireAt``DateTime?`)。扫描前建议新增 `@@index([status, payExpireAt])`。
## 6. 实现建议
- 在 `MonitorScheduler` 新增 `@Cron`(如每 1 分钟 `*/1 * * * *`)或扩展 `scanStuckOrders`,调用 `TradeService.autoCancelExpiredOrders()`:分页取 `status:PENDING_PAY && payExpireAt<now`,对每个订单用**条件更新** `updateMany({ where: { id, status: 'PENDING_PAY' }, data: { status: 'CANCELLED', cancelledAt: now } })`,并写 `commonEvent`operator=`'SYSTEM_AUTO_CANCEL'`)。
- 状态枚举引用:service 层当前直接用字符串 `'PENDING_PAY'`/`'CANCELLED'`(也可 `import { OrderStatus } from 'shared-types'`,二者值一致)。
- 失败处理沿用现有 `alert.notify` 模式(level P0/P1 + dedupeKey)。
## 7. 风险点
1. **分布式锁/幂等**:多实例下 `scanStuckOrders` 当前无锁(`monitor.scheduler` 已注入 `RedisService`,可加 Redis 分布式锁防重复执行);`where:{status:'PENDING_PAY'}` 条件更新保证幂等。
2. **无重复任务冲突**:当前仅 monitor 扫描告警,尚无真正取消任务,新增即补缺口,不会与既有任务重复。支付路径已在校验 `payExpireAt<now` 拒绝支付(:2053、:2142),与自动取消逻辑自洽。
3. **批量规模**:用 `take` 分页 + `orderBy payExpireAt asc`,勿一次性全量 update,避免长事务。
4. **索引缺失**:扫描前先加 `@@index([status, payExpireAt])`,否则全表扫描。
## 8. 关键文件清单
- `server/dukang-api/prisma/schema.prisma`OrderStatus:321Order 模型:1412-1499;索引:1488-1497
- `packages/shared-types/src/enums.ts`OrderStatus:71
- `server/dukang-api/src/modules/trade/trade.service.ts`createOrder:153payExpireAt:206/1590/1935applyStatusTransition:1324cancelPartnerProxyOrder:1791;支付过期校验:2053/2142
- `server/dukang-api/src/modules/trade/trade.controller.ts`cancel-pay:261
- `server/dukang-api/src/jobs/monitor.scheduler.ts`scanStuckOrders:54
- `server/dukang-api/src/jobs/settlement.scheduler.ts`@Cron 范例)
- `server/dukang-api/src/jobs/jobs.module.ts`、`jobs.constants.ts`、`delivery.processor.ts`