# 杜康好客 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 }` | 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 { 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` | 头部加 `