Files
dukang/docs/杜康好客-v3.5.1-工单迭代开发文档.md
T
2026-08-19 16:20:33 +08:00

664 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 杜康好客 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`