merge(dev_jacy): 下单与核销接口访问限制

This commit is contained in:
2026-09-26 14:01:17 +08:00
12 changed files with 60 additions and 11 deletions
+5 -1
View File
@@ -200,7 +200,11 @@ export default function ApiAccessPage() {
? '不拦截审核,只按比例丢弃门店审核通知'
: row.featureKey === 'login'
? 'C 端、门店、合伙人登录;总部登录始终放行'
: '命中后直接返回失败文案',
: row.featureKey === 'order_create'
? '现场提货和选择地址配送都拦截,并返回失败文案'
: row.featureKey === 'redeem_confirm'
? '扫码核销、手机号核销和弱网待处理都拦截,并返回失败文案'
: '命中后直接返回失败文案',
},
]}
/>
+5 -2
View File
@@ -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<string>(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;
@@ -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);
}
@@ -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);
}
+1 -1
View File
@@ -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 入口:合伙人中心 → 运营管理 → 推广码数据。验收:只配渠道负责人不绑用户;只配关联合伙人会进「关联用户」且能看三项汇总;已关联他人扫码流程不中断。
+3 -1
View File
@@ -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`,不改页面。
+1 -1
View File
@@ -9,7 +9,7 @@
| # | 任务 | 类型 | 交付 |
|---|------|------|------|
| 1 | 成功百分比 | 需求 | 登录、商品加载、门店加载、门店提交按比例放行;可按用户、账户、功能覆盖 |
| 1 | 成功百分比 | 需求 | 登录、商品加载、门店加载、门店提交、下单、核销按比例放行;可按用户、账户、功能覆盖 |
| 2 | 审核通知百分比 | 需求 | 不拦审核写入;`store.audit_pending` 按比例丢弃 |
| 3 | 通知总开关 | 需求 | 订单/核销/账单/门店审核/套餐修改只决定发不发企微 |
+1 -1
View File
@@ -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. 企业微信
+22
View File
@@ -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();
});
});
+11 -1
View File
@@ -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;
}
+4
View File
@@ -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<ApiAccessPercentFeature,
product_load: '商品加载',
store_load: '门店加载',
store_submit: '门店提交',
order_create: '下单',
redeem_confirm: '核销',
audit_notify: '审核通知',
};
@@ -102,7 +102,7 @@ export class ApiAccessService implements OnModuleInit {
async updateGlobals(items: ApiAccessGlobalItem[]): Promise<ApiAccessFormResponse> {
await this.adminReady();
if (!Array.isArray(items) || items.length !== API_ACCESS_PERCENT_FEATURES.length) {
throw new BadRequestException('需同时提交五个功能的成功百分比');
throw new BadRequestException('需同时提交全部功能的成功百分比');
}
const seen = new Set<string>();
for (const item of items) {