664 lines
37 KiB
Markdown
664 lines
37 KiB
Markdown
# 杜康好客 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.findUnique,diff 出 changedFields
|
||
// 3) prisma.storeInfoChangeRequest.create(live/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 / PARTNER,USER 跳过**):
|
||
- `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 依赖 M3;M6 无依赖;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 存 localStorage(ACCESS_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→401,403→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:321;Order 模型:1412-1499;索引:1488-1497)
|
||
- `packages/shared-types/src/enums.ts`(OrderStatus:71)
|
||
- `server/dukang-api/src/modules/trade/trade.service.ts`(createOrder:153;payExpireAt:206/1590/1935;applyStatusTransition:1324;cancelPartnerProxyOrder: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`
|