37 KiB
杜康好客 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 + 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(发票抬头)
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: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):
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(强制脱敏)、下单时间 |
关键实现:
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.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)
// 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. 待确认的设计细节(如需开工前敲定)
- 发票抬头列表页是否需要"设为默认"按钮?
- 门店信息变更驳回后,原表单是否需要提示驳回原因?
- 大屏是否需要支持"暂停轮播"按钮?
- 用户端
My → 发票管理是否需要再放"申请记录"二级入口? - #8 权益回滚:自动取消订单时是否需要把已占用的
benefitCoupon置回ACTIVE?(现有手动取消cancelPartnerProxyOrder未回滚,建议保持一致或明确要回滚) - #8 扫描粒度:每分钟一次(
*/1 * * * *)是否可接受?还是希望 2~5 分钟一次? - #9 校验范围:需求只提"城市合伙人 + 门店"。是否也要覆盖
h5-shop的城市合伙人代运营账号(proxyPartnerAccountId场景)?目前按"仅 STORE + PARTNER actor"实现。 - #9 提示文案:强退登录时前端是否需要 toast 提示"账号已停用/解绑,请重新登录"再跳登录页,还是静默跳回?
- #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:1050if (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:573partner/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);
USERactorType 跳过,不影响 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: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.createOrdersrc/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:13ScheduleModule.forRoot(),并注册 BullMQDELIVERY_QUEUE(jobs.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:54scanStuckOrders()已每 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):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. 风险点
- 分布式锁/幂等:多实例下
scanStuckOrders当前无锁(monitor.scheduler已注入RedisService,可加 Redis 分布式锁防重复执行);where:{status:'PENDING_PAY'}条件更新保证幂等。 - 无重复任务冲突:当前仅 monitor 扫描告警,尚无真正取消任务,新增即补缺口,不会与既有任务重复。支付路径已在校验
payExpireAt<now拒绝支付(:2053、:2142),与自动取消逻辑自洽。 - 批量规模:用
take分页 +orderBy payExpireAt asc,勿一次性全量 update,避免长事务。 - 索引缺失:扫描前先加
@@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