From d306ffaea82fec04ddd65f31fd9831360eb4866d Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sat, 26 Sep 2026 14:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E4=B8=8B=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E7=BA=B3=E5=85=A5=E6=8E=A5=E5=8F=A3=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E7=99=BE=E5=88=86=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 总部可按成功百分比拦截现场提货、配送下单,以及扫码、手机号和弱网核销,规则写入 v4 文档。 Co-authored-by: Cursor --- apps/admin-web/src/pages/ApiAccessPage.tsx | 6 ++++- apps/h5-shop/src/lib/redeem-failure.ts | 7 ++++-- .../src/pages/order-confirm-pickup/index.tsx | 4 +++- .../src/pages/order-confirm/index.tsx | 4 +++- docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-v4-PRD.md | 4 +++- docs/杜康好客-v4.0.21-开发文档.md | 2 +- docs/杜康好客-知识库.md | 2 +- packages/domain/src/api-access.test.ts | 22 +++++++++++++++++++ packages/domain/src/api-access.ts | 12 +++++++++- packages/shared-types/src/api-access.ts | 4 ++++ .../common/api-access/api-access.service.ts | 2 +- 12 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/admin-web/src/pages/ApiAccessPage.tsx b/apps/admin-web/src/pages/ApiAccessPage.tsx index fad363e..87df071 100644 --- a/apps/admin-web/src/pages/ApiAccessPage.tsx +++ b/apps/admin-web/src/pages/ApiAccessPage.tsx @@ -200,7 +200,11 @@ export default function ApiAccessPage() { ? '不拦截审核,只按比例丢弃门店审核通知' : row.featureKey === 'login' ? 'C 端、门店、合伙人登录;总部登录始终放行' - : '命中后直接返回失败文案', + : row.featureKey === 'order_create' + ? '现场提货和选择地址配送都拦截,并返回失败文案' + : row.featureKey === 'redeem_confirm' + ? '扫码核销、手机号核销和弱网待处理都拦截,并返回失败文案' + : '命中后直接返回失败文案', }, ]} /> diff --git a/apps/h5-shop/src/lib/redeem-failure.ts b/apps/h5-shop/src/lib/redeem-failure.ts index 0cff208..3897236 100644 --- a/apps/h5-shop/src/lib/redeem-failure.ts +++ b/apps/h5-shop/src/lib/redeem-failure.ts @@ -1,9 +1,12 @@ -import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types'; +import { API_ACCESS_ERROR_KINDS, REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types'; import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types'; import { request } from './api'; +const API_ACCESS_DENIAL_REASONS = new Set(API_ACCESS_ERROR_KINDS); + export function isNetworkError(e: unknown): boolean { - const err = e as Error & { status?: number }; + const err = e as Error & { status?: number; reason?: string }; + if (err.reason && API_ACCESS_DENIAL_REASONS.has(err.reason)) return false; const status = err.status; const message = (err.message ?? String(e)).toLowerCase(); if (status != null && status >= 500) return true; diff --git a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx index 272a58e..819bf58 100644 --- a/apps/mini-user/src/pages/order-confirm-pickup/index.tsx +++ b/apps/mini-user/src/pages/order-confirm-pickup/index.tsx @@ -164,7 +164,9 @@ export default function OrderConfirmPickupPage() { try { await doSubmit(); } catch (e) { - setMsg(e instanceof Error ? e.message : '下单失败'); + const tip = e instanceof Error ? e.message : '下单失败'; + setMsg(tip); + toast(tip); } finally { setLoading(false); } diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 5ac83c2..f8a3f10 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -296,7 +296,9 @@ export default function OrderConfirmPage() { try { await doSubmit(); } catch (e) { - setMsg(e instanceof Error ? e.message : '下单失败'); + const tip = e instanceof Error ? e.message : '下单失败'; + setMsg(tip); + toast(tip); } finally { setLoading(false); } diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index d6b81cd..69ebc7a 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -68,7 +68,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。 -**接口访问(v4.0.21)**:规则见 v4-PRD §10 与 [`v4.0.21 开发文档`](./杜康好客-v4.0.21-开发文档.md)。表 `api_access_policy`(common)。HQ `GET/PUT /admin/api-access`、`POST/DELETE /admin/api-access/overrides`、`GET /admin/api-access/actors`(权限 `api_access`)。百分比默认 100;通知开关默认开。总部登录与配置接口不拦截。 +**接口访问(v4.0.21)**:规则见 v4-PRD §10 与 [`v4.0.21 开发文档`](./杜康好客-v4.0.21-开发文档.md)。表 `api_access_policy`(common)。HQ `GET/PUT /admin/api-access`、`POST/DELETE /admin/api-access/overrides`、`GET /admin/api-access/actors`(权限 `api_access`)。百分比默认 100;通知开关默认开。总部登录与配置接口不拦截。下单限制只拦 `POST /trade/orders`(现场提货与选地址配送),失败文案由小程序 toast 展示。核销限制拦 `POST /shop/redeem/confirm`、`/phone/confirm`、`/pending`,失败文案由门店端 toast 展示,不计入弱网失败。 **推广码渠道负责人 / 关联合伙人(v4.0.20)**:规则见 v4-PRD §2.1 与 [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md)。表 `promo_code_channel_owner`(多对多主合伙人)+ `common_promo_code.assoc_partner_account_id`。HQ `POST/PUT /admin/promo-codes` 字段 `channelOwnerPartnerIds`、`assocPartnerAccountId`(须主账号 ACTIVE)。`POST /promo/touch` 登录后对关联合伙人 `PartnerCityService.tryBindIfUnbound`(已绑他人静默跳过,不回刷历史;Promo 不 import StoreModule)。合伙人主账号 `GET /partner/promo-codes` 仅返回 `scanCount` / `attributionCount` / `orderCount`(已完成订单),禁止用户/事件/订单明细。H5 入口:合伙人中心 → 运营管理 → 推广码数据。验收:只配渠道负责人不绑用户;只配关联合伙人会进「关联用户」且能看三项汇总;已关联他人扫码流程不中断。 diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md index 357b757..3cf95d0 100644 --- a/docs/杜康好客-v4-PRD.md +++ b/docs/杜康好客-v4-PRD.md @@ -17,7 +17,7 @@ | 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) | | 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) | | 4.0.20 | 09-16 / 09-17 | 推广码渠道负责人多选主合伙人(H5 只看三项汇总);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 | [`v4.0.20`](./杜康好客-v4.0.20-开发文档.md) | -| 4.0.21 | 09-26 | HQ 接口访问:登录/商品/门店加载/门店提交按成功百分比放行;审核通知按百分比丢弃;订单/核销/账单/门店审核/套餐修改仅作企微通知总开关 | [`v4.0.21`](./杜康好客-v4.0.21-开发文档.md) | +| 4.0.21 | 09-26 | HQ 接口访问:登录/商品/门店加载/门店提交/下单/核销按成功百分比放行;审核通知按百分比丢弃;订单/核销/账单/门店审核/套餐修改仅作企微通知总开关 | [`v4.0.21`](./杜康好客-v4.0.21-开发文档.md) | ## 1. 锚点(沿用 V3,佣金归属改写) @@ -145,6 +145,8 @@ HQ「接口访问」(权限 `api_access`,危险权限,超管可用,其 - **登录**:C 端 / 门店 / 合伙人登录。未登录时若 body 有手机号,先匹配该手机号的用户或账户规则。**总部登录不参与**,避免百分比打成 0 后无法改回。配置接口本身不参与。 - **商品加载**:`GET /catalog/products`、`GET /catalog/products/:id`。**门店加载**:`GET /stores`、`GET /stores/:id`。不含总部后台读接口。 - **门店提交**:`POST /partner/stores` 新建入驻。 +- **下单**:`POST /trade/orders`。现场提货和选择地址配送共用这一接口,命中后不下单,返回所选失败文案。预览、支付、总部代下单、合伙人代下单不在此列。 +- **核销**:`POST /shop/redeem/confirm`、`POST /shop/redeem/phone/confirm`、`POST /shop/redeem/pending`。扫码确认、手机号确认和弱网待处理命中后不核销,门店端弹出所选失败文案,且不计入弱网失败次数。预览、发码、出码、总部核销调试和待处理补核销不在此列。 - **审核通知**:不拦截审核写入;发送 `store.audit_pending` 时按百分比丢弃。 - 被拒绝的 HTTP 返回 `{ code, message, reason }`,文案为网络加载失败 / 请求异常 / 非法访问 / 微信服务异常(规则可选,默认请求异常)。**不发企微告警**。各端展示 `message`,不改页面。 diff --git a/docs/杜康好客-v4.0.21-开发文档.md b/docs/杜康好客-v4.0.21-开发文档.md index 40cdf12..a5417ab 100644 --- a/docs/杜康好客-v4.0.21-开发文档.md +++ b/docs/杜康好客-v4.0.21-开发文档.md @@ -9,7 +9,7 @@ | # | 任务 | 类型 | 交付 | |---|------|------|------| -| 1 | 成功百分比 | 需求 | 登录、商品加载、门店加载、门店提交按比例放行;可按用户、账户、功能覆盖 | +| 1 | 成功百分比 | 需求 | 登录、商品加载、门店加载、门店提交、下单、核销按比例放行;可按用户、账户、功能覆盖 | | 2 | 审核通知百分比 | 需求 | 不拦审核写入;`store.audit_pending` 按比例丢弃 | | 3 | 通知总开关 | 需求 | 订单/核销/账单/门店审核/套餐修改只决定发不发企微 | diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index eba1ec1..32f9fef 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -143,7 +143,7 @@ HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下 ## 16. 系统设置 -HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。接口访问(`/api-access`,权限 `api_access`):按功能/账户/用户设成功百分比;订单/核销/账单/门店审核/套餐修改只关企微通知。 +HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。接口访问(`/api-access`,权限 `api_access`):按功能/账户/用户设成功百分比(含下单、核销);订单/核销/账单/门店审核/套餐修改的开关只关企微通知。 ## 17. 企业微信 diff --git a/packages/domain/src/api-access.test.ts b/packages/domain/src/api-access.test.ts index 0bba308..90f3d87 100644 --- a/packages/domain/src/api-access.test.ts +++ b/packages/domain/src/api-access.test.ts @@ -137,6 +137,17 @@ describe('classifyApiAccessRoute', () => { expect(classifyApiAccessRoute('GET', '/api/v1/stores')).toEqual({ feature: 'store_load' }); expect(classifyApiAccessRoute('GET', '/api/v1/stores/9')).toEqual({ feature: 'store_load' }); expect(classifyApiAccessRoute('POST', '/api/v1/partner/stores')).toEqual({ feature: 'store_submit' }); + expect(classifyApiAccessRoute('POST', '/api/v1/trade/orders')).toEqual({ feature: 'order_create' }); + expect(classifyApiAccessRoute('POST', '/trade/orders')).toEqual({ feature: 'order_create' }); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/confirm')).toEqual({ + feature: 'redeem_confirm', + }); + expect(classifyApiAccessRoute('POST', '/shop/redeem/phone/confirm')).toEqual({ + feature: 'redeem_confirm', + }); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/pending')).toEqual({ + feature: 'redeem_confirm', + }); }); it('skips headquarters login, admin reads, and nested store routes', () => { @@ -146,6 +157,17 @@ describe('classifyApiAccessRoute', () => { expect(classifyApiAccessRoute('GET', '/api/v1/catalog/cities')).toBeNull(); expect(classifyApiAccessRoute('GET', '/api/v1/stores/9/recent-redeems')).toBeNull(); expect(classifyApiAccessRoute('POST', '/api/v1/partner/stores/send-phone-sms')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/trade/orders/preview')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/trade/orders/12/pay')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/admin/proxy-orders')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/partner/proxy-orders')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/preview')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/phone/prepare')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/phone/prepare-direct')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/redeem/failures')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/redeem/tokens')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/admin/redeem/debug/confirm')).toBeNull(); + expect(classifyApiAccessRoute('POST', '/api/v1/admin/redeem/pending/1/complete')).toBeNull(); }); }); diff --git a/packages/domain/src/api-access.ts b/packages/domain/src/api-access.ts index 0eaa5d4..365256b 100644 --- a/packages/domain/src/api-access.ts +++ b/packages/domain/src/api-access.ts @@ -3,6 +3,8 @@ export const API_ACCESS_PERCENT_FEATURE_KEYS = [ 'product_load', 'store_load', 'store_submit', + 'order_create', + 'redeem_confirm', 'audit_notify', ] as const; @@ -102,7 +104,7 @@ export function allowBySuccessPercent(successPercent: number, roll: number): boo } export type ApiAccessRouteHit = { - feature: 'login' | 'product_load' | 'store_load' | 'store_submit'; + feature: 'login' | 'product_load' | 'store_load' | 'store_submit' | 'order_create' | 'redeem_confirm'; loginChannel?: 'user' | 'store' | 'partner'; }; @@ -123,6 +125,14 @@ export function classifyApiAccessRoute(method: string, url: string): ApiAccessRo return { feature: 'login', loginChannel: 'partner' }; } if (path === '/partner/stores') return { feature: 'store_submit' }; + if (path === '/trade/orders') return { feature: 'order_create' }; + if ( + path === '/shop/redeem/confirm' || + path === '/shop/redeem/phone/confirm' || + path === '/shop/redeem/pending' + ) { + return { feature: 'redeem_confirm' }; + } return null; } diff --git a/packages/shared-types/src/api-access.ts b/packages/shared-types/src/api-access.ts index 796eed5..7d0c1dd 100644 --- a/packages/shared-types/src/api-access.ts +++ b/packages/shared-types/src/api-access.ts @@ -4,6 +4,8 @@ export const API_ACCESS_PERCENT_FEATURES = [ 'product_load', 'store_load', 'store_submit', + 'order_create', + 'redeem_confirm', 'audit_notify', ] as const; @@ -14,6 +16,8 @@ export const API_ACCESS_PERCENT_FEATURE_LABELS: Record { await this.adminReady(); if (!Array.isArray(items) || items.length !== API_ACCESS_PERCENT_FEATURES.length) { - throw new BadRequestException('需同时提交五个功能的成功百分比'); + throw new BadRequestException('需同时提交全部功能的成功百分比'); } const seen = new Set(); for (const item of items) {