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

37 KiB
Raw Permalink Blame History

杜康好客 v3.5.1 开发方案

9 个修改点 · 涉及 4 端(admin-web / h5-shop / mini-user / h5-partner+ 1 后端 落地对照(已实现)杜康好客-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 + 30minline 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(发票抬头)

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(门店信息变更请求)

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:generatecd 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):

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(强制脱敏)、下单时间

关键实现:

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 新增:

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 新增:

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 增加:

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

@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)(新增方法)

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.tsproviders 增加 OrderExpirySchedulerTradeModule 已 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)statusACTIVE;若 req.user.storeId 存在,查 StorestatusOPEN
      • 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.tsline 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新建 简单 hookuseRedeemSuccessListener(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/indexinvoice-title-edit/indexinvoice-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 submitStoreInfoChangeRequestlistStoreInfoChangeRequests

兼容老路径:保留 /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 ?? nullrequestWithAuthRetry 的不可恢复分支(~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

// 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 分钟内状态自动变 CANCELLEDcancelledAt 写入、状态日志 SYSTEM_AUTO_EXPIRE 存在
  • #9:将某门店 StoreAccount.status 置非 ACTIVE(或 Store.statusOPEN),其门店端任意接口返回 reason: ACCOUNT_DISABLED,前端立即 clearAuth() 并跳 /login
  • #9:将某合伙人 bindingStatus 置非 ACTIVE,合伙人端任意接口触发强退登录;mini-userUSER 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

  • Storeline 1245):只有 status StoreStatus @default(PAUSED)line 1268),没有 bindstatus 字段
    • enum StoreStatusline 282):OPEN / PAUSED / CLOSED。"开启" = OPEN
  • PartnerAccountline 1030):status AccountStatus @default(ACTIVE)line 1042+ bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")line 1049)。
    • enum AccountStatusline 244):ACTIVE / DISABLED
    • enum CityPartnerStatusline 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
  • JwtAuthGuardjwt-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(运行时):
    • ShopStoreGuardshop-store.guard.ts:1-15):仅校验 user.storeId 存在。
    • PartnerPrimaryGuardpartner-primary.guard.ts:11-32):查 partnerAccount + 校验 isPrimary==1
    • PartnerPermissionGuardpartner-permission.guard.ts:14-45):查 partnerAccount + 校验权限。
    • ShopPrimaryGuardshop-primary.guard.ts):查 storeAccount.isPrimary
  • 关键缺口:登录后账号被停用/解绑,运行时接口不拦截(仅登录时校验过 StoreAccount.status)。

10.3 前端登录态与退出

  • h5-shop/src/lib/api.tstoken 存 localStorageACCESS_TOKEN/REFRESH_TOKEN),profile 存 STORE_PROFILEclearAuth136-145)清 token。
    • rawRequest191-227):json.code !== 0 抛 Errorerr.status = (>=500?res.status:json.code)401→401403→403)。
    • requestWithAuthRetry249-270):仅 err.status===401 且非豁免路径时尝试 refresh;失败则 clearAuth() 并 throw。shop 端 request 没有跳登录页逻辑
    • 登录路由 /loginAuthGate.tsx:5、多处 navigate('/login'))。
  • h5-partner/src/lib/api.ts:结构同构;request217-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 存在,查 Storestatus 必须 OPEN
  • PARTNER:查 PartnerAccount(actorId)status===ACTIVEbindingStatus===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() + 跳 /loginpartner 已在 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/meselect-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
    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)。
  • 下单时间 createdAtschema.prisma:1400/1473);支付时间 paidAt:1455);取消时间 cancelledAt(:1458,已存在可复用,无需新增字段)。

3. 现有定时任务机制

  • 已启用 @nestjs/schedulesrc/jobs/jobs.module.ts:13 ScheduleModule.forRoot(),并注册 BullMQ DELIVERY_QUEUEjobs.constants.ts,用于 delivery.processor.ts)。
  • @Cron 完整示例 src/jobs/settlement.scheduler.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 + 写 commonEventbuildOrderStatusEvent),但不处理 cancelledAt / payStatus
  • grep 确认建单与取消均无库存占用/权益发放(无 stock/inventory/decrement/release 调用,仅 benefitCoupon 出现在查询 include)。→ 自动取消只需翻状态+写日志,无需回滚库存/释放权益
  • 建议:新增 TradeService.autoCancelExpiredOrders(),复用 applyStatusTransition 但补 cancelledAt 写入(或在内联事务中同时置 status/ cancelledAt/ payStatus)。

5. 查询未支付订单

  • 现成写法(monitor.scheduler.ts:61):
    this.prisma.order.findMany({
      where: { status: 'PENDING_PAY', payExpireAt: { lt: now } },
      select: { orderNo: true }, take: sample, orderBy: { payExpireAt: 'asc' },
    });
    
  • 建议加 payStatus: 'UNPAID' 过滤更稳妥;字段即 payExpireAtDateTime?)。扫描前建议新增 @@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 } }),并写 commonEventoperator='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.prismaOrderStatus:321Order 模型:1412-1499;索引:1488-1497
  • packages/shared-types/src/enums.tsOrderStatus:71
  • server/dukang-api/src/modules/trade/trade.service.tscreateOrder:153payExpireAt:206/1590/1935applyStatusTransition:1324cancelPartnerProxyOrder:1791;支付过期校验:2053/2142
  • server/dukang-api/src/modules/trade/trade.controller.tscancel-pay:261
  • server/dukang-api/src/jobs/monitor.scheduler.tsscanStuckOrders:54
  • server/dukang-api/src/jobs/settlement.scheduler.ts@Cron 范例)
  • server/dukang-api/src/jobs/jobs.module.tsjobs.constants.tsdelivery.processor.ts