diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 204977c..946ce69 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -49,6 +49,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage'; import WechatBindingsPage from './pages/WechatBindingsPage'; import HqPermissionsPage from './pages/HqPermissionsPage'; import SystemSettingsPage from './pages/SystemSettingsPage'; +import ApiAccessPage from './pages/ApiAccessPage'; import TestWhitelistPage from './pages/TestWhitelistPage'; import WecomBotsPage from './pages/WecomBotsPage'; import WecomApiPluginsPage from './pages/WecomApiPluginsPage'; @@ -155,6 +156,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 9deb398..d7c1cca 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -17,6 +17,7 @@ import { FileTextOutlined, LockOutlined, SettingOutlined, + ApiOutlined, AccountBookOutlined, ProjectOutlined, PictureOutlined, @@ -164,6 +165,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/hq-permissions', icon: , label: '权限分配' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, { key: '/test-whitelist', icon: , label: '白名单管理' }, + { key: '/api-access', icon: , label: '接口访问' }, { key: '/system-settings', icon: , label: '系统设置' }, ]; @@ -240,6 +242,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/logs/domain-events': 'logs', '/hq-permissions': 'hq_permissions', '/test-whitelist': 'test_whitelist', + '/api-access': 'api_access', '/system-settings': 'system_settings_any', '/hq-accounts': 'hq_accounts', }; diff --git a/apps/admin-web/src/pages/ApiAccessPage.tsx b/apps/admin-web/src/pages/ApiAccessPage.tsx new file mode 100644 index 0000000..fad363e --- /dev/null +++ b/apps/admin-web/src/pages/ApiAccessPage.tsx @@ -0,0 +1,426 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Card, + Form, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Typography, + message, +} from 'antd'; +import { + API_ACCESS_ERROR_KINDS, + API_ACCESS_ERROR_LABELS, + API_ACCESS_PERCENT_FEATURES, + API_ACCESS_PERCENT_FEATURE_LABELS, + type ApiAccessActorHit, + type ApiAccessErrorKind, + type ApiAccessFormResponse, + type ApiAccessPercentFeature, + type ApiAccessPolicyDto, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; + +const ERROR_OPTIONS = API_ACCESS_ERROR_KINDS.map((value) => ({ + value, + label: API_ACCESS_ERROR_LABELS[value], +})); + +const FEATURE_OPTIONS = [ + { value: '', label: '全部功能' }, + ...API_ACCESS_PERCENT_FEATURES.map((value) => ({ + value, + label: API_ACCESS_PERCENT_FEATURE_LABELS[value], + })), +]; + +type PercentDraft = { + featureKey: ApiAccessPercentFeature; + featureLabel: string; + successPercent: number; + errorKind: ApiAccessErrorKind; +}; + +export default function ApiAccessPage() { + const [loading, setLoading] = useState(false); + const [savingGlobals, setSavingGlobals] = useState(false); + const [savingNotifies, setSavingNotifies] = useState(false); + const [percents, setPercents] = useState([]); + const [overrides, setOverrides] = useState([]); + const [notifies, setNotifies] = useState([]); + const [open, setOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await request('/admin/api-access'); + setPercents( + data.percents.map((row) => ({ + featureKey: row.featureKey as ApiAccessPercentFeature, + featureLabel: row.featureLabel, + successPercent: row.successPercent ?? 100, + errorKind: row.errorKind ?? 'request_error', + })), + ); + setOverrides(data.overrides); + setNotifies(data.notifies); + } catch (error) { + message.error(error instanceof Error ? error.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function saveGlobals() { + setSavingGlobals(true); + try { + const data = await request('/admin/api-access/globals', { + method: 'PUT', + body: JSON.stringify({ + items: percents.map((row) => ({ + featureKey: row.featureKey, + successPercent: row.successPercent, + errorKind: row.errorKind, + })), + }), + }); + setOverrides(data.overrides); + message.success('已保存成功百分比'); + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败'); + } finally { + setSavingGlobals(false); + } + } + + async function saveNotifies() { + setSavingNotifies(true); + try { + await request('/admin/api-access/notifies', { + method: 'PUT', + body: JSON.stringify({ + items: notifies.map((row) => ({ featureKey: row.featureKey, enabled: row.enabled })), + }), + }); + message.success('已保存通知开关'); + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败'); + } finally { + setSavingNotifies(false); + } + } + + async function removeOverride(id: string) { + try { + const data = await request(`/admin/api-access/overrides/${id}`, { + method: 'DELETE', + }); + setOverrides(data.overrides); + message.success('已删除'); + } catch (error) { + message.error(error instanceof Error ? error.message : '删除失败'); + } + } + + return ( + +
+ + 接口访问 + + + 成功百分比是放行比例,默认 100。0 为全部拒绝,每次请求单独计算。可按用户或账户覆盖。总部登录不受登录百分比影响。被拒绝的请求只返回所选文案,不发企微。通知开关只停止对应企微消息,不拦截下单、核销、出账、审核和改套餐。 + +
+ + void saveGlobals()}> + 保存 + + } + > + ( + + setPercents((list) => + list.map((item) => + item.featureKey === row.featureKey + ? { ...item, successPercent: typeof value === 'number' ? value : 100 } + : item, + ), + ) + } + /> + ), + }, + { + title: '失败文案', + render: (_, row) => ( +
(row.scopeType === 'user' ? '用户' : '账户'), + }, + { + title: '对象', + render: (_, row) => row.actorLabel || `${row.actorType} ${row.actorId}`, + }, + { title: '功能', dataIndex: 'featureLabel', width: 140 }, + { title: '成功百分比', dataIndex: 'successPercent', width: 120 }, + { + title: '失败文案', + width: 160, + render: (_, row) => (row.errorKind ? API_ACCESS_ERROR_LABELS[row.errorKind] : '请求异常'), + }, + { + title: '操作', + width: 100, + render: (_, row) => ( + void removeOverride(row.id)}> + + + ), + }, + ]} + /> + + + void saveNotifies()}> + 保存 + + } + > +
( + + setNotifies((list) => + list.map((item) => (item.featureKey === row.featureKey ? { ...item, enabled } : item)), + ) + } + /> + ), + }, + ]} + /> + + + setOpen(false)} + onCreated={(data) => { + setOverrides(data.overrides); + setOpen(false); + }} + /> + + ); +} + +function OverrideModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (data: ApiAccessFormResponse) => void; +}) { + const [form] = Form.useForm(); + const [scope, setScope] = useState<'user' | 'account'>('user'); + const [hits, setHits] = useState([]); + const [searching, setSearching] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + form.resetFields(); + form.setFieldsValue({ + scopeType: 'user', + featureKey: '', + successPercent: 100, + errorKind: 'request_error', + }); + setScope('user'); + setHits([]); + }, [open, form]); + + async function onSearch(q: string) { + const keyword = q.trim(); + if (!keyword) { + setHits([]); + return; + } + setSearching(true); + try { + const rows = await request( + `/admin/api-access/actors?scope=${scope}&q=${encodeURIComponent(keyword)}`, + ); + setHits(rows); + } catch (error) { + message.error(error instanceof Error ? error.message : '搜索失败'); + } finally { + setSearching(false); + } + } + + async function submit() { + const values = await form.validateFields(); + const hit = hits.find((item) => `${item.actorType}:${item.actorId}` === values.actor); + if (!hit) { + message.error('请选择对象'); + return; + } + setSaving(true); + try { + const data = await request('/admin/api-access/overrides', { + method: 'POST', + body: JSON.stringify({ + scopeType: scope, + actorType: hit.actorType, + actorId: hit.actorId, + featureKey: values.featureKey || null, + successPercent: values.successPercent, + errorKind: values.errorKind, + }), + }); + message.success('已添加'); + onCreated(data); + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败'); + } finally { + setSaving(false); + } + } + + return ( + void submit()} + confirmLoading={saving} + destroyOnClose + > +
+ + void onSearch(q)} + options={hits.map((hit) => ({ + value: `${hit.actorType}:${hit.actorId}`, + label: hit.label, + }))} + /> + + + + + +
+ ); +} diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index f3e3a71..6135a91 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -68,6 +68,8 @@ 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.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 入口:合伙人中心 → 运营管理 → 推广码数据。验收:只配渠道负责人不绑用户;只配关联合伙人会进「关联用户」且能看三项汇总;已关联他人扫码流程不中断。 **合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。 diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md index 7e07a3c..2e74fb7 100644 --- a/docs/杜康好客-v4-PRD.md +++ b/docs/杜康好客-v4-PRD.md @@ -1,9 +1,8 @@ # 杜康好客 · V4 PRD -> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录;**v4.0.20** 推广码渠道负责人/关联合伙人 -> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。 -> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。 -> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) +> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录;**v4.0.20** 推广码渠道负责人/关联合伙人;**v4.0.21** 接口访问 +> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户、接口访问)。 +> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md) · [`v4.0.21 开发文档`](./杜康好客-v4.0.21-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) ## 0. 版本 @@ -18,6 +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) | ## 1. 锚点(沿用 V3,佣金归属改写) @@ -133,6 +133,29 @@ HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查 - 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。 - 权限:`finance`。 -## 10. 不做 +## 10. 接口访问(v4.0.21) + +HQ「接口访问」(权限 `api_access`,危险权限,超管可用,其他角色需单独勾选)。表 `api_access_policy`。默认成功百分比 **100**、通知开关开,与未配置时一致。 + +成功百分比是放行比例,每次请求独立随机:`0` 全拒,`100` 全放行。命中顺序:用户+功能 → 账户+功能 → 该用户全部功能 → 该账户全部功能 → 功能全局 → 放行。 + +- **用户**:C 端 `User`。**账户**:总部 `HqAccount`、门店 `StoreAccount`、合伙人 `PartnerAccount`。 +- **登录**:C 端 / 门店 / 合伙人登录。未登录时若 body 有手机号,先匹配该手机号的用户或账户规则。**总部登录不参与**,避免百分比打成 0 后无法改回。配置接口本身不参与。 +- **商品加载**:`GET /catalog/products`、`GET /catalog/products/:id`。**门店加载**:`GET /stores`、`GET /stores/:id`。不含总部后台读接口。 +- **门店提交**:`POST /partner/stores` 新建入驻。 +- **审核通知**:不拦截审核写入;发送 `store.audit_pending` 时按百分比丢弃。 +- 被拒绝的 HTTP 返回 `{ code, message, reason }`,文案为网络加载失败 / 请求异常 / 非法访问 / 微信服务异常(规则可选,默认请求异常)。**不发企微告警**。各端展示 `message`,不改页面。 + +通知总开关只决定还发不发企微,不拦下单、核销、出账、审核、改套餐。关掉后即使「消息推送」勾了条件也不发;打开后仍走原条件。运营日报/周报/月报不在此列(仍在「企微机器人 → 报告」)。 + +| 开关 | 事件 | +|------|------| +| 订单推送 | `order.paid` | +| 核销推送 | `redeem.success` | +| 酒厂 / 城市合伙人 / 门店账单 | `finance.winery_bill` / `finance.partner_bill` / `finance.store_bill` | +| 门店审核 | `store.audit_pending`(与审核通知百分比叠加:关则不发,开则只成功该百分比) | +| 套餐修改 | `store.package_audit_pending` | + +## 11. 不做 不把推广码改成关联码、不回刷历史绑定;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。 diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md index 182b580..d60f32c 100644 --- a/docs/杜康好客-v4-现状对照.md +++ b/docs/杜康好客-v4-现状对照.md @@ -3,11 +3,11 @@ > 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) > V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。 -## 0. 总览(2026-09-17) +## 0. 总览(2026-09-26) | 维度 | 结论 | |------|------| -| 版本线 | **v4.0.20** 推广码渠道负责人 + 关联合伙人(含 v4.0.18 子账号继承码 / 财务全部银行账户) | +| 版本线 | **v4.0.21** 接口访问(含 v4.0.20 推广码渠道负责人 + 关联合伙人) | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | | 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) | | 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 | @@ -26,6 +26,7 @@ | 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 | | 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 | | 4.0.20 | [`推广码渠道负责人 + 关联合伙人`](./杜康好客-v4.0.20-开发文档.md) | ✅ 已实现 | +| 4.0.21 | [`接口访问`](./杜康好客-v4.0.21-开发文档.md) | ✅ 已实现 | | 日期 | 说明 | |------|------| @@ -45,3 +46,4 @@ | 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) | | 2026-09-16 | v4.0.20:推广码渠道负责人改为可多选主合伙人(H5 只看扫码/归因/订单数);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 | | 2026-09-17 | v4.0.20:PromoModule 改走 CityScope 避免 Nest 循环依赖;本地 Vite 代理默认 `127.0.0.1:3010` | +| 2026-09-26 | v4.0.21:HQ 接口访问。登录/商品加载/门店加载/门店提交按成功百分比放行;审核通知按百分比丢弃企微;订单/核销/账单/门店审核/套餐修改只做通知总开关。总部登录不参与 | diff --git a/docs/杜康好客-v4.0.21-开发文档.md b/docs/杜康好客-v4.0.21-开发文档.md new file mode 100644 index 0000000..40cdf12 --- /dev/null +++ b/docs/杜康好客-v4.0.21-开发文档.md @@ -0,0 +1,55 @@ +# 杜康好客 · v4.0.21 开发文档 + +> **2026-09-26** · common / ops / admin-web / domain / shared-types / wecom +> **主题**:接口访问(成功百分比 + 企微通知总开关) + +--- + +## 1. 版本目标 + +| # | 任务 | 类型 | 交付 | +|---|------|------|------| +| 1 | 成功百分比 | 需求 | 登录、商品加载、门店加载、门店提交按比例放行;可按用户、账户、功能覆盖 | +| 2 | 审核通知百分比 | 需求 | 不拦审核写入;`store.audit_pending` 按比例丢弃 | +| 3 | 通知总开关 | 需求 | 订单/核销/账单/门店审核/套餐修改只决定发不发企微 | + +**不做**:运营日报/周报/月报开关(仍在企微机器人 → 报告);用开关拦截下单、核销、出账、审核、改套餐;改各端错误页。 + +--- + +## 2. 规则 + +规则事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §10。 + +- 默认百分比 100、通知开。每次请求独立随机。`0` 全拒,`100` 全放行。 +- 命中:用户+功能 → 账户+功能 → 用户全部功能 → 账户全部功能 → 功能全局 → 放行。 +- 总部 `POST /admin/auth/login/*` 与 `/admin/api-access` 不拦截。 +- 拒绝文案四选一,默认「请求异常」;不推企微。 +- 表未就绪时拦截失败即放行,避免未 `db push` 时把登录打满。 + +--- + +## 3. 数据与接口 + +表 `api_access_policy`(common OWNER)。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/api-access` | 五个全局百分比、覆盖规则、七个通知开关 | +| PUT | `/admin/api-access/globals` | 保存全局百分比与失败文案 | +| PUT | `/admin/api-access/notifies` | 保存通知开关 | +| POST | `/admin/api-access/overrides` | 新增账户或用户覆盖 | +| DELETE | `/admin/api-access/overrides/:id` | 删除覆盖 | +| GET | `/admin/api-access/actors?scope=user\|account&q=` | 搜索作用对象 | + +权限 `api_access`(危险权限)。超管 bypass;开发者默认集不含该键。 + +--- + +## 4. 验收 + +- [ ] 百分比 100 时登录、商品、门店列表与现网一致 +- [ ] 调低后对应请求按比例返回所选文案,且不推企微 +- [ ] 总部登录在登录百分比为 0 时仍可用 +- [ ] 关掉「订单推送」后支付仍完成,企微不再发 `order.paid` +- [ ] 门店审核开关开、审核通知百分比低于 100 时,审核写入成功,通知按比例丢弃 diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index 5e3a54d..eba1ec1 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -143,7 +143,7 @@ HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下 ## 16. 系统设置 -HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。 +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 new file mode 100644 index 0000000..0bba308 --- /dev/null +++ b/packages/domain/src/api-access.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + allowBySuccessPercent, + classifyApiAccessRoute, + decideWecomSend, + resolveApiAccessPercent, + type ApiAccessPercentRule, +} from './api-access'; + +function rule(partial: Partial & Pick): ApiAccessPercentRule { + return { + actorType: '', + actorId: '0', + successPercent: 100, + errorKind: 'request_error', + ...partial, + }; +} + +describe('resolveApiAccessPercent', () => { + const rules: ApiAccessPercentRule[] = [ + rule({ scopeType: 'global', featureKey: 'login', successPercent: 90, errorKind: 'request_error' }), + rule({ + scopeType: 'account', + featureKey: '', + actorType: 'PARTNER', + actorId: '7', + successPercent: 40, + errorKind: 'network_load_failed', + }), + rule({ + scopeType: 'account', + featureKey: 'login', + actorType: 'PARTNER', + actorId: '7', + successPercent: 10, + errorKind: 'illegal_access', + }), + rule({ + scopeType: 'user', + featureKey: '', + actorType: 'USER', + actorId: '3', + successPercent: 70, + }), + rule({ + scopeType: 'user', + featureKey: 'product_load', + actorType: 'USER', + actorId: '3', + successPercent: 5, + errorKind: 'wechat_service_error', + }), + ]; + + it('uses the most specific matching rule', () => { + expect(resolveApiAccessPercent(rules, 'login', { accountType: 'PARTNER', accountId: '7' })).toEqual({ + successPercent: 10, + errorKind: 'illegal_access', + }); + expect(resolveApiAccessPercent(rules, 'store_submit', { accountType: 'PARTNER', accountId: '7' })).toEqual({ + successPercent: 40, + errorKind: 'network_load_failed', + }); + expect(resolveApiAccessPercent(rules, 'product_load', { userId: '3' })).toEqual({ + successPercent: 5, + errorKind: 'wechat_service_error', + }); + expect(resolveApiAccessPercent(rules, 'store_load', { userId: '3' })).toEqual({ + successPercent: 70, + errorKind: 'request_error', + }); + expect(resolveApiAccessPercent(rules, 'login', {})).toEqual({ + successPercent: 90, + errorKind: 'request_error', + }); + }); + + it('prefers a user rule over an account rule on the same feature', () => { + const mixed = [ + ...rules, + rule({ + scopeType: 'account', + featureKey: 'product_load', + actorType: 'HQ', + actorId: '1', + successPercent: 1, + }), + ]; + expect( + resolveApiAccessPercent(mixed, 'product_load', { + userId: '3', + accountType: 'HQ', + accountId: '1', + }).successPercent, + ).toBe(5); + }); + + it('allows when nothing matches', () => { + expect(resolveApiAccessPercent([], 'login', {})).toEqual({ + successPercent: 100, + errorKind: 'request_error', + }); + }); +}); + +describe('allowBySuccessPercent', () => { + it('always allows 100 and always denies 0', () => { + expect(allowBySuccessPercent(100, 99.9)).toBe(true); + expect(allowBySuccessPercent(0, 0)).toBe(false); + expect(allowBySuccessPercent(150, 99)).toBe(true); + expect(allowBySuccessPercent(-1, 0)).toBe(false); + }); + + it('compares the roll against the percent', () => { + expect(allowBySuccessPercent(50, 49.9)).toBe(true); + expect(allowBySuccessPercent(50, 50)).toBe(false); + }); +}); + +describe('classifyApiAccessRoute', () => { + it('matches client login, catalog, stores, and partner store create', () => { + expect(classifyApiAccessRoute('POST', '/api/v1/auth/login/sms')).toEqual({ + feature: 'login', + loginChannel: 'user', + }); + expect(classifyApiAccessRoute('POST', '/api/v1/shop/auth/login/wechat')).toEqual({ + feature: 'login', + loginChannel: 'store', + }); + expect(classifyApiAccessRoute('POST', '/api/v1/partner/auth/login/sms?x=1')).toEqual({ + feature: 'login', + loginChannel: 'partner', + }); + expect(classifyApiAccessRoute('GET', '/catalog/products')).toEqual({ feature: 'product_load' }); + expect(classifyApiAccessRoute('GET', '/api/v1/catalog/products/12')).toEqual({ feature: 'product_load' }); + 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' }); + }); + + it('skips headquarters login, admin reads, and nested store routes', () => { + expect(classifyApiAccessRoute('POST', '/api/v1/admin/auth/login/password')).toBeNull(); + expect(classifyApiAccessRoute('PUT', '/api/v1/admin/api-access/globals')).toBeNull(); + expect(classifyApiAccessRoute('GET', '/api/v1/admin/products')).toBeNull(); + 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(); + }); +}); + +describe('decideWecomSend', () => { + it('stops when the notify switch is off', () => { + expect( + decideWecomSend({ + notifyEnabled: false, + applyAuditPercent: true, + successPercent: 100, + roll: 0, + }), + ).toBe('switch_off'); + }); + + it('drops audit notifications below the percent', () => { + expect( + decideWecomSend({ + notifyEnabled: true, + applyAuditPercent: true, + successPercent: 20, + roll: 20, + }), + ).toBe('percent_drop'); + expect( + decideWecomSend({ + notifyEnabled: null, + applyAuditPercent: false, + successPercent: 0, + roll: 0, + }), + ).toBe('allow'); + }); +}); diff --git a/packages/domain/src/api-access.ts b/packages/domain/src/api-access.ts new file mode 100644 index 0000000..0eaa5d4 --- /dev/null +++ b/packages/domain/src/api-access.ts @@ -0,0 +1,161 @@ +export const API_ACCESS_PERCENT_FEATURE_KEYS = [ + 'login', + 'product_load', + 'store_load', + 'store_submit', + 'audit_notify', +] as const; + +export type ApiAccessPercentFeatureKey = (typeof API_ACCESS_PERCENT_FEATURE_KEYS)[number]; + +export type ApiAccessAccountType = 'HQ' | 'STORE' | 'PARTNER'; + +export interface ApiAccessSubject { + userId?: string | null; + accountType?: ApiAccessAccountType | null; + accountId?: string | null; +} + +export interface ApiAccessPercentRule { + featureKey: string; + scopeType: 'global' | 'account' | 'user'; + actorType: string; + actorId: string; + successPercent: number; + errorKind: string; +} + +export interface ResolvedApiAccessPercent { + successPercent: number; + errorKind: string; +} + +const DEFAULT_PERCENT: ResolvedApiAccessPercent = { + successPercent: 100, + errorKind: 'request_error', +}; + +/** + * 更具体的规则覆盖更宽的规则。 + * 50 用户+功能,40 账户+功能,30 用户全部功能,20 账户全部功能,10 功能全局。 + */ +export function resolveApiAccessPercent( + rules: ApiAccessPercentRule[], + feature: string, + subject: ApiAccessSubject, +): ResolvedApiAccessPercent { + let bestScore = 0; + let best: ApiAccessPercentRule | null = null; + for (const rule of rules) { + const score = scoreApiAccessRule(rule, feature, subject); + if (score > bestScore) { + bestScore = score; + best = rule; + } + } + if (!best) return DEFAULT_PERCENT; + const percent = clampPercent(best.successPercent); + return { + successPercent: percent, + errorKind: best.errorKind || DEFAULT_PERCENT.errorKind, + }; +} + +function scoreApiAccessRule( + rule: ApiAccessPercentRule, + feature: string, + subject: ApiAccessSubject, +): number { + const featureSpecific = rule.featureKey === feature; + const featureAll = rule.featureKey === ''; + if (!featureSpecific && !featureAll) return 0; + + if (rule.scopeType === 'user') { + if (!subject.userId || rule.actorType !== 'USER' || rule.actorId !== subject.userId) return 0; + return featureSpecific ? 50 : 30; + } + if (rule.scopeType === 'account') { + if (!subject.accountId || !subject.accountType) return 0; + if (rule.actorType !== subject.accountType || rule.actorId !== subject.accountId) return 0; + return featureSpecific ? 40 : 20; + } + if (rule.scopeType === 'global' && featureSpecific) return 10; + return 0; +} + +export function clampPercent(value: number): number { + const n = Math.floor(Number(value)); + if (!Number.isFinite(n)) return 100; + if (n <= 0) return 0; + if (n >= 100) return 100; + return n; +} + +/** roll 为 [0, 100)。100 恒放行,0 恒拒绝。 */ +export function allowBySuccessPercent(successPercent: number, roll: number): boolean { + const percent = clampPercent(successPercent); + if (percent >= 100) return true; + if (percent <= 0) return false; + const sample = Number(roll); + if (!Number.isFinite(sample)) return true; + return sample < percent; +} + +export type ApiAccessRouteHit = { + feature: 'login' | 'product_load' | 'store_load' | 'store_submit'; + loginChannel?: 'user' | 'store' | 'partner'; +}; + +/** 识别需要百分比拦截的 HTTP 路径。总部登录与配置接口返回 null。 */ +export function classifyApiAccessRoute(method: string, url: string): ApiAccessRouteHit | null { + const verb = method.toUpperCase(); + const path = normalizeApiPath(url); + if (path.startsWith('/admin/auth/login') || path.startsWith('/admin/api-access')) return null; + + if (verb === 'POST') { + if (path === '/auth/login/sms' || path === '/auth/login/wechat' || path === '/auth/login/wechat-phone') { + return { feature: 'login', loginChannel: 'user' }; + } + if (path === '/shop/auth/login/sms' || path === '/shop/auth/login/wechat') { + return { feature: 'login', loginChannel: 'store' }; + } + if (path === '/partner/auth/login/sms' || path === '/partner/auth/login/wechat') { + return { feature: 'login', loginChannel: 'partner' }; + } + if (path === '/partner/stores') return { feature: 'store_submit' }; + return null; + } + + if (verb === 'GET') { + if (path === '/catalog/products' || /^\/catalog\/products\/\d+$/.test(path)) { + return { feature: 'product_load' }; + } + if (path === '/stores' || /^\/stores\/\d+$/.test(path)) { + return { feature: 'store_load' }; + } + } + return null; +} + +export function normalizeApiPath(url: string): string { + const raw = url.split('?')[0] || '/'; + const stripped = raw.replace(/^\/api\/v1(?=\/|$)/, '') || '/'; + if (stripped.length > 1 && stripped.endsWith('/')) return stripped.slice(0, -1); + return stripped || '/'; +} + +export type WecomDispatchGate = 'allow' | 'switch_off' | 'percent_drop'; + +/** 通知开关优先于审核通知百分比。notifyEnabled 为 null 表示该事件没有总开关。 */ +export function decideWecomSend(args: { + notifyEnabled: boolean | null; + applyAuditPercent: boolean; + successPercent: number; + roll: number; +}): WecomDispatchGate { + if (args.notifyEnabled === false) return 'switch_off'; + if (args.applyAuditPercent && !allowBySuccessPercent(args.successPercent, args.roll)) { + return 'percent_drop'; + } + return 'allow'; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index cdaf0fd..5dd1213 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -416,3 +416,4 @@ export * from './wecom-report'; export * from './wecom-plugin'; export * from './shipping-address'; export * from './store-address'; +export * from './api-access'; diff --git a/packages/shared-types/src/api-access.ts b/packages/shared-types/src/api-access.ts new file mode 100644 index 0000000..796eed5 --- /dev/null +++ b/packages/shared-types/src/api-access.ts @@ -0,0 +1,145 @@ +/** HQ 接口访问:成功百分比功能(v4.0.21) */ +export const API_ACCESS_PERCENT_FEATURES = [ + 'login', + 'product_load', + 'store_load', + 'store_submit', + 'audit_notify', +] as const; + +export type ApiAccessPercentFeature = (typeof API_ACCESS_PERCENT_FEATURES)[number]; + +export const API_ACCESS_PERCENT_FEATURE_LABELS: Record = { + login: '登录', + product_load: '商品加载', + store_load: '门店加载', + store_submit: '门店提交', + audit_notify: '审核通知', +}; + +/** 只控制企微是否发送,不拦截业务 */ +export const API_ACCESS_NOTIFY_KEYS = [ + 'order_push', + 'redeem_push', + 'bill_push_winery', + 'bill_push_partner', + 'bill_push_store', + 'store_audit', + 'package_change', +] as const; + +export type ApiAccessNotifyKey = (typeof API_ACCESS_NOTIFY_KEYS)[number]; + +export const API_ACCESS_NOTIFY_LABELS: Record = { + order_push: '订单推送', + redeem_push: '核销推送', + bill_push_winery: '酒厂账单推送', + bill_push_partner: '城市合伙人账单推送', + bill_push_store: '门店账单推送', + store_audit: '门店审核', + package_change: '套餐修改', +}; + +/** 企微 eventKey → 通知总开关。未列出的事件不受开关影响 */ +export const API_ACCESS_NOTIFY_BY_EVENT: Record = { + 'order.paid': 'order_push', + 'redeem.success': 'redeem_push', + 'finance.winery_bill': 'bill_push_winery', + 'finance.partner_bill': 'bill_push_partner', + 'finance.store_bill': 'bill_push_store', + 'store.audit_pending': 'store_audit', + 'store.package_audit_pending': 'package_change', +}; + +/** 审核通知百分比只作用在这一条企微事件上 */ +export const API_ACCESS_AUDIT_NOTIFY_EVENT = 'store.audit_pending'; + +export const API_ACCESS_ERROR_KINDS = [ + 'network_load_failed', + 'request_error', + 'illegal_access', + 'wechat_service_error', +] as const; + +export type ApiAccessErrorKind = (typeof API_ACCESS_ERROR_KINDS)[number]; + +export const API_ACCESS_DEFAULT_ERROR_KIND: ApiAccessErrorKind = 'request_error'; + +export const API_ACCESS_ERROR_LABELS: Record = { + network_load_failed: '网络加载失败', + request_error: '请求异常', + illegal_access: '非法访问', + wechat_service_error: '微信服务异常', +}; + +export const API_ACCESS_ERROR_STATUS: Record = { + network_load_failed: 503, + request_error: 503, + illegal_access: 403, + wechat_service_error: 503, +}; + +export const API_ACCESS_ACTOR_TYPES = ['USER', 'STORE', 'PARTNER', 'HQ'] as const; +export type ApiAccessActorType = (typeof API_ACCESS_ACTOR_TYPES)[number]; + +export const API_ACCESS_ACCOUNT_ACTOR_TYPES = ['STORE', 'PARTNER', 'HQ'] as const; +export type ApiAccessAccountActorType = (typeof API_ACCESS_ACCOUNT_ACTOR_TYPES)[number]; + +export interface ApiAccessPolicyDto { + id: string; + featureKey: string | null; + featureLabel: string; + scopeType: 'global' | 'account' | 'user'; + actorType: ApiAccessActorType | null; + actorId: string | null; + actorLabel: string | null; + successPercent: number | null; + errorKind: ApiAccessErrorKind | null; + enabled: boolean; +} + +export interface ApiAccessFormResponse { + percents: ApiAccessPolicyDto[]; + overrides: ApiAccessPolicyDto[]; + notifies: ApiAccessPolicyDto[]; +} + +export interface ApiAccessGlobalItem { + featureKey: ApiAccessPercentFeature; + successPercent: number; + errorKind: ApiAccessErrorKind; +} + +export interface ApiAccessNotifyItem { + featureKey: ApiAccessNotifyKey; + enabled: boolean; +} + +export interface ApiAccessOverrideCreate { + scopeType: 'account' | 'user'; + actorType: ApiAccessActorType; + actorId: string; + /** null = 该对象的全部百分比功能 */ + featureKey: ApiAccessPercentFeature | null; + successPercent: number; + errorKind: ApiAccessErrorKind; +} + +export interface ApiAccessActorHit { + actorType: ApiAccessActorType; + actorId: string; + label: string; + phone: string | null; +} + +export function isApiAccessPercentFeature(value: string): value is ApiAccessPercentFeature { + return (API_ACCESS_PERCENT_FEATURES as readonly string[]).includes(value); +} + +export function isApiAccessNotifyKey(value: string): value is ApiAccessNotifyKey { + return (API_ACCESS_NOTIFY_KEYS as readonly string[]).includes(value); +} + +export function isApiAccessErrorKind(value: string): value is ApiAccessErrorKind { + return (API_ACCESS_ERROR_KINDS as readonly string[]).includes(value); +} diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index c8bca97..f62ab05 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -46,6 +46,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'system_settings_deploy', label: '发布部署', group: '系统设置' }, { key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' }, { key: 'system_settings_finance', label: '财务结算', group: '系统设置' }, + { key: 'api_access', label: '接口访问', group: '系统设置' }, ] as const; export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; @@ -76,6 +77,7 @@ export const HQ_DANGEROUS_PERMISSION_KEYS = [ 'orders_delete', 'cities_delete', 'store_categories_delete', + 'api_access', ] as const satisfies readonly HqPermissionKey[]; export function isHqDangerousPermission(key: string): boolean { @@ -175,7 +177,7 @@ const OPS_STORE_KEYS: HqPermissionKey[] = [ /** 开发者默认:除权限分配、HQ 账户外全部权限 */ function developerDefaultPermissions(): HqPermissionKey[] { return HQ_PERMISSION_CATALOG.map((p) => p.key).filter( - (k) => k !== 'hq_permissions' && k !== 'hq_accounts', + (k) => k !== 'hq_permissions' && k !== 'hq_accounts' && k !== 'api_access', ); } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 2a544dd..d457b3b 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -36,3 +36,4 @@ export * from './llm-config'; export * from './knowledge-base'; export * from './legal'; export * from './dev-plan'; +export * from './api-access'; diff --git a/server/dukang-api/AGENTS.md b/server/dukang-api/AGENTS.md index 00d094f..b81f47a 100644 --- a/server/dukang-api/AGENTS.md +++ b/server/dukang-api/AGENTS.md @@ -19,7 +19,7 @@ | **settlement** | StorePayout, PartnerBill | jacy-dukang | | **ops** | 只读聚合、ActivityPoster | jacy-dukang | | **analytics** | LogUserAnalytics | jacy-dukang | -| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang | +| **common** | CommonResource, CommonEvent, CommonTicket, ApiAccessPolicy | jacy-dukang | | **integrations** | 无表 | jacy-dukang | **log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。 diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 5dde6fe..6ba6eb6 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -469,6 +469,25 @@ model SystemConfig { @@map("system_config") } +/// HQ 接口访问:成功百分比与企微通知总开关(v4.0.21) +model ApiAccessPolicy { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + kind String @db.VarChar(16) + featureKey String @default("") @map("feature_key") @db.VarChar(32) + scopeType String @map("scope_type") @db.VarChar(16) + actorType String @default("") @map("actor_type") @db.VarChar(16) + actorId BigInt @default(0) @map("actor_id") @db.UnsignedBigInt + successPercent Int? @map("success_percent") + errorKind String? @map("error_kind") @db.VarChar(32) + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + @@unique([kind, featureKey, scopeType, actorType, actorId], map: "api_access_policy_key") + @@index([kind, scopeType]) + @@map("api_access_policy") +} + /// 企业微信智能机器人(HQ 可创建多实例,长连接) model WecomBot { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index acbca92..c039814 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module'; import { CommonModule } from './modules/common/common.module'; import { HqOperationModule } from './common/hq-operation/hq-operation.module'; import { SystemConfigModule } from './common/system-config/system-config.module'; +import { ApiAccessModule } from './common/api-access/api-access.module'; import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module'; import { DevPlanModule } from './modules/dev-plan/dev-plan.module'; import { CallbacksModule } from './callbacks/callbacks.module'; @@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware'; }, }), PrismaModule, + ApiAccessModule, SystemConfigModule, TestWhitelistModule, GeoModule, diff --git a/server/dukang-api/src/common/alert/alert.module.ts b/server/dukang-api/src/common/alert/alert.module.ts index a9ce662..c254498 100644 --- a/server/dukang-api/src/common/alert/alert.module.ts +++ b/server/dukang-api/src/common/alert/alert.module.ts @@ -1,5 +1,6 @@ import { Global, Module } from '@nestjs/common'; import { RedisModule } from '../redis/redis.module'; +import { ApiAccessModule } from '../api-access/api-access.module'; import { AlertService } from './alert.service'; import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; @@ -9,7 +10,7 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message- */ @Global() @Module({ - imports: [RedisModule], + imports: [RedisModule, ApiAccessModule], providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService], exports: [WecomMessagePushService, AlertService, PayRedeemAnomalyService], }) diff --git a/server/dukang-api/src/common/api-access/api-access.exception.ts b/server/dukang-api/src/common/api-access/api-access.exception.ts new file mode 100644 index 0000000..8736f91 --- /dev/null +++ b/server/dukang-api/src/common/api-access/api-access.exception.ts @@ -0,0 +1,19 @@ +import { HttpException } from '@nestjs/common'; +import { + API_ACCESS_ERROR_LABELS, + API_ACCESS_ERROR_STATUS, + type ApiAccessErrorKind, +} from '@dukang/shared-types'; + +export class ApiAccessDeniedException extends HttpException { + constructor(errorKind: ApiAccessErrorKind) { + super( + { + message: API_ACCESS_ERROR_LABELS[errorKind], + reason: errorKind, + apiAccessDenied: true, + }, + API_ACCESS_ERROR_STATUS[errorKind], + ); + } +} diff --git a/server/dukang-api/src/common/api-access/api-access.interceptor.ts b/server/dukang-api/src/common/api-access/api-access.interceptor.ts new file mode 100644 index 0000000..cbfc7c4 --- /dev/null +++ b/server/dukang-api/src/common/api-access/api-access.interceptor.ts @@ -0,0 +1,69 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import type { ApiAccessSubject } from '@dukang/domain'; +import { Observable } from 'rxjs'; +import { ApiAccessDeniedException } from './api-access.exception'; +import { ApiAccessService } from './api-access.service'; + +type AccessRequest = { + method?: string; + url?: string; + originalUrl?: string; + body?: { phone?: unknown }; + user?: { actorType?: string; actorId?: bigint | string }; +}; + +@Injectable() +export class ApiAccessInterceptor implements NestInterceptor { + private readonly logger = new Logger(ApiAccessInterceptor.name); + + constructor(private readonly apiAccess: ApiAccessService) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + if (context.getType() !== 'http') return next.handle(); + const req = context.switchToHttp().getRequest(); + const hit = this.apiAccess.classify(req.method || 'GET', req.originalUrl || req.url || ''); + if (!hit) return next.handle(); + + try { + const subject = await this.resolveSubject(req, hit.loginChannel); + const decision = await this.apiAccess.evaluateHttp(hit.feature, subject); + if (!decision.allow) { + this.logger.log(`api access denied feature=${hit.feature} error=${decision.errorKind}`); + throw new ApiAccessDeniedException(decision.errorKind); + } + } catch (error) { + if (error instanceof ApiAccessDeniedException) throw error; + this.logger.warn( + `api access check skipped: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return next.handle(); + } + + private async resolveSubject( + req: AccessRequest, + loginChannel?: 'user' | 'store' | 'partner', + ): Promise { + if (loginChannel) { + const phone = typeof req.body?.phone === 'string' ? req.body.phone : ''; + if (phone.trim()) return this.apiAccess.findLoginSubject(loginChannel, phone); + } + return subjectFromUser(req.user); + } +} + +function subjectFromUser(user: AccessRequest['user']): ApiAccessSubject { + if (!user?.actorId || !user.actorType) return {}; + const id = user.actorId.toString(); + if (user.actorType === 'USER') return { userId: id }; + if (user.actorType === 'STORE' || user.actorType === 'PARTNER' || user.actorType === 'HQ') { + return { accountType: user.actorType, accountId: id }; + } + return {}; +} diff --git a/server/dukang-api/src/common/api-access/api-access.module.ts b/server/dukang-api/src/common/api-access/api-access.module.ts new file mode 100644 index 0000000..ed7f090 --- /dev/null +++ b/server/dukang-api/src/common/api-access/api-access.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { ApiAccessInterceptor } from './api-access.interceptor'; +import { ApiAccessService } from './api-access.service'; + +@Global() +@Module({ + providers: [ApiAccessService, { provide: APP_INTERCEPTOR, useClass: ApiAccessInterceptor }], + exports: [ApiAccessService], +}) +export class ApiAccessModule {} diff --git a/server/dukang-api/src/common/api-access/api-access.service.ts b/server/dukang-api/src/common/api-access/api-access.service.ts new file mode 100644 index 0000000..8f35e9e --- /dev/null +++ b/server/dukang-api/src/common/api-access/api-access.service.ts @@ -0,0 +1,650 @@ +import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { + allowBySuccessPercent, + classifyApiAccessRoute, + decideWecomSend, + resolveApiAccessPercent, + type ApiAccessPercentRule, + type ApiAccessSubject, + type WecomDispatchGate, +} from '@dukang/domain'; +import { + API_ACCESS_ACCOUNT_ACTOR_TYPES, + API_ACCESS_AUDIT_NOTIFY_EVENT, + API_ACCESS_DEFAULT_ERROR_KIND, + API_ACCESS_ERROR_KINDS, + API_ACCESS_NOTIFY_BY_EVENT, + API_ACCESS_NOTIFY_KEYS, + API_ACCESS_NOTIFY_LABELS, + API_ACCESS_PERCENT_FEATURE_LABELS, + API_ACCESS_PERCENT_FEATURES, + isApiAccessErrorKind, + isApiAccessNotifyKey, + isApiAccessPercentFeature, + type ApiAccessActorHit, + type ApiAccessActorType, + type ApiAccessErrorKind, + type ApiAccessFormResponse, + type ApiAccessGlobalItem, + type ApiAccessNotifyItem, + type ApiAccessOverrideCreate, + type ApiAccessPercentFeature, + type ApiAccessPolicyDto, +} from '@dukang/shared-types'; +import { PrismaService } from '../prisma/prisma.module'; +import { parseBigIntParam } from '../parse-bigint'; + +const CACHE_MS = 5000; + +type PolicyRow = { + id: bigint; + kind: string; + featureKey: string; + scopeType: string; + actorType: string; + actorId: bigint; + successPercent: number | null; + errorKind: string | null; + enabled: boolean; +}; + +@Injectable() +export class ApiAccessService implements OnModuleInit { + private readonly logger = new Logger(ApiAccessService.name); + private ensured = false; + private missingLogged = false; + private cache: { at: number; percents: ApiAccessPercentRule[]; notifies: Map } | null = + null; + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + try { + await this.ensureDefaults(); + } catch (error) { + if (!this.noteMissing(error)) { + this.logger.warn( + `api access ensureDefaults failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + async getForm(): Promise { + await this.adminReady(); + const rows = await this.prisma.apiAccessPolicy.findMany({ orderBy: { id: 'asc' } }); + const percents = API_ACCESS_PERCENT_FEATURES.map((feature) => { + const row = rows.find( + (item) => + item.kind === 'percent' && + item.scopeType === 'global' && + item.featureKey === feature && + item.actorId === BigInt(0), + ); + return this.toDto(row, feature); + }); + const overrides = rows.filter((item) => item.kind === 'percent' && item.scopeType !== 'global'); + const labels = await this.labelsFor(overrides); + const notifies = API_ACCESS_NOTIFY_KEYS.map((feature) => { + const row = rows.find( + (item) => item.kind === 'notify' && item.scopeType === 'global' && item.featureKey === feature, + ); + return this.toNotifyDto(row, feature); + }); + return { + percents, + overrides: overrides.map((row) => this.toDto(row, null, labels)), + notifies, + }; + } + + async updateGlobals(items: ApiAccessGlobalItem[]): Promise { + await this.adminReady(); + if (!Array.isArray(items) || items.length !== API_ACCESS_PERCENT_FEATURES.length) { + throw new BadRequestException('需同时提交五个功能的成功百分比'); + } + const seen = new Set(); + for (const item of items) { + if (!isApiAccessPercentFeature(item.featureKey) || seen.has(item.featureKey)) { + throw new BadRequestException('功能无效'); + } + seen.add(item.featureKey); + const percent = this.assertPercent(item.successPercent); + const errorKind = this.assertErrorKind(item.errorKind); + await this.prisma.apiAccessPolicy.upsert({ + where: { + kind_featureKey_scopeType_actorType_actorId: { + kind: 'percent', + featureKey: item.featureKey, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + }, + }, + create: { + kind: 'percent', + featureKey: item.featureKey, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + successPercent: percent, + errorKind, + enabled: true, + }, + update: { successPercent: percent, errorKind }, + }); + } + this.invalidate(); + return this.getForm(); + } + + async updateNotifies(items: ApiAccessNotifyItem[]): Promise { + await this.adminReady(); + if (!Array.isArray(items) || items.length !== API_ACCESS_NOTIFY_KEYS.length) { + throw new BadRequestException('需同时提交全部通知开关'); + } + const seen = new Set(); + for (const item of items) { + if (!isApiAccessNotifyKey(item.featureKey) || seen.has(item.featureKey)) { + throw new BadRequestException('通知开关无效'); + } + seen.add(item.featureKey); + if (typeof item.enabled !== 'boolean') throw new BadRequestException('开关须为布尔值'); + await this.prisma.apiAccessPolicy.upsert({ + where: { + kind_featureKey_scopeType_actorType_actorId: { + kind: 'notify', + featureKey: item.featureKey, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + }, + }, + create: { + kind: 'notify', + featureKey: item.featureKey, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + enabled: item.enabled, + }, + update: { enabled: item.enabled }, + }); + } + this.invalidate(); + return this.getForm(); + } + + async createOverride(dto: ApiAccessOverrideCreate): Promise { + await this.adminReady(); + const actorId = parseBigIntParam(dto.actorId, '对象'); + if (actorId === BigInt(0)) throw new BadRequestException('对象无效'); + const featureKey: string = dto.featureKey ?? ''; + if (featureKey !== '' && !isApiAccessPercentFeature(featureKey)) { + throw new BadRequestException('功能无效'); + } + const percent = this.assertPercent(dto.successPercent); + const errorKind = this.assertErrorKind(dto.errorKind); + if (dto.scopeType === 'user') { + if (dto.actorType !== 'USER') throw new BadRequestException('用户覆盖只能选择 C 端用户'); + const user = await this.prisma.user.findUnique({ where: { id: actorId }, select: { id: true } }); + if (!user) throw new BadRequestException('用户不存在'); + } else if (dto.scopeType === 'account') { + if (!(API_ACCESS_ACCOUNT_ACTOR_TYPES as readonly string[]).includes(dto.actorType)) { + throw new BadRequestException('账户类型无效'); + } + const exists = await this.accountExists(dto.actorType, actorId); + if (!exists) throw new BadRequestException('账户不存在'); + } else { + throw new BadRequestException('范围无效'); + } + + try { + await this.prisma.apiAccessPolicy.create({ + data: { + kind: 'percent', + featureKey, + scopeType: dto.scopeType, + actorType: dto.actorType, + actorId, + successPercent: percent, + errorKind, + enabled: true, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new BadRequestException('该对象在此功能上已有规则'); + } + throw error; + } + this.invalidate(); + return this.getForm(); + } + + async deleteOverride(id: bigint): Promise { + await this.adminReady(); + const row = await this.prisma.apiAccessPolicy.findUnique({ where: { id } }); + if (!row || row.kind !== 'percent' || row.scopeType === 'global') { + throw new BadRequestException('只能删除账户或用户覆盖'); + } + await this.prisma.apiAccessPolicy.delete({ where: { id } }); + this.invalidate(); + return this.getForm(); + } + + async searchActors(scope: string, q: string): Promise { + const keyword = q.trim(); + if (!keyword) return []; + if (scope === 'user') { + const rows = await this.prisma.user.findMany({ + where: { + OR: [ + { phone: { contains: keyword } }, + { nickname: { contains: keyword } }, + { userNo: { contains: keyword } }, + ], + }, + take: 20, + orderBy: { id: 'desc' }, + select: { id: true, phone: true, nickname: true, userNo: true }, + }); + return rows.map((row) => ({ + actorType: 'USER' as const, + actorId: row.id.toString(), + phone: row.phone, + label: `用户 ${row.nickname || row.userNo}${row.phone ? `(${row.phone})` : ''}`, + })); + } + if (scope !== 'account') throw new BadRequestException('范围无效'); + const [hq, stores, partners] = await Promise.all([ + this.prisma.hqAccount.findMany({ + where: { + OR: [{ phone: { contains: keyword } }, { name: { contains: keyword } }, { loginName: { contains: keyword } }], + }, + take: 10, + orderBy: { id: 'desc' }, + select: { id: true, phone: true, name: true }, + }), + this.prisma.storeAccount.findMany({ + where: { OR: [{ phone: { contains: keyword } }, { name: { contains: keyword } }] }, + take: 10, + orderBy: { id: 'desc' }, + select: { id: true, phone: true, name: true }, + }), + this.prisma.partnerAccount.findMany({ + where: { + OR: [ + { phone: { contains: keyword } }, + { name: { contains: keyword } }, + { companyName: { contains: keyword } }, + ], + }, + take: 10, + orderBy: { id: 'desc' }, + select: { id: true, phone: true, name: true, companyName: true }, + }), + ]); + return [ + ...hq.map((row) => this.accountHit('HQ', row.id, `总部 ${row.name}`, row.phone)), + ...stores.map((row) => this.accountHit('STORE', row.id, `门店 ${row.name}`, row.phone)), + ...partners.map((row) => + this.accountHit('PARTNER', row.id, `合伙人 ${row.companyName || row.name}`, row.phone), + ), + ].slice(0, 20); + } + + async evaluateHttp( + feature: ApiAccessPercentFeature, + subject: ApiAccessSubject, + ): Promise<{ allow: boolean; errorKind: ApiAccessErrorKind }> { + try { + const rules = await this.loadPercentRules(); + const resolved = resolveApiAccessPercent(rules, feature, subject); + const errorKind = isApiAccessErrorKind(resolved.errorKind) + ? resolved.errorKind + : API_ACCESS_DEFAULT_ERROR_KIND; + return { + allow: allowBySuccessPercent(resolved.successPercent, Math.random() * 100), + errorKind, + }; + } catch (error) { + if (this.noteMissing(error)) { + return { allow: true, errorKind: API_ACCESS_DEFAULT_ERROR_KIND }; + } + throw error; + } + } + + async isNotifySwitchOff(eventKey: string): Promise { + const notifyKey = API_ACCESS_NOTIFY_BY_EVENT[eventKey]; + if (!notifyKey) return false; + try { + const notifies = await this.loadNotifyMap(); + return notifies.get(notifyKey) === false; + } catch (error) { + if (!this.noteMissing(error)) { + this.logger.warn(`api access notify switch read failed: ${error instanceof Error ? error.message : String(error)}`); + } + return false; + } + } + + async gateWecom(eventKey: string, subject: ApiAccessSubject = {}): Promise { + try { + const notifyKey = API_ACCESS_NOTIFY_BY_EVENT[eventKey] ?? null; + const notifies = await this.loadNotifyMap(); + const notifyEnabled = notifyKey ? (notifies.get(notifyKey) ?? true) : null; + const applyAuditPercent = eventKey === API_ACCESS_AUDIT_NOTIFY_EVENT; + let successPercent = 100; + if (applyAuditPercent && notifyEnabled !== false) { + const resolved = resolveApiAccessPercent(await this.loadPercentRules(), 'audit_notify', subject); + successPercent = resolved.successPercent; + } + return decideWecomSend({ + notifyEnabled, + applyAuditPercent: applyAuditPercent && notifyEnabled !== false, + successPercent, + roll: Math.random() * 100, + }); + } catch (error) { + if (!this.noteMissing(error)) { + this.logger.warn(`api access wecom gate failed: ${error instanceof Error ? error.message : String(error)}`); + } + return 'allow'; + } + } + + async findLoginSubject( + channel: 'user' | 'store' | 'partner', + phone: string, + ): Promise { + const normalized = phone.trim(); + if (!normalized) return {}; + try { + if (channel === 'user') { + const user = await this.prisma.user.findUnique({ + where: { phone: normalized }, + select: { id: true }, + }); + return user ? { userId: user.id.toString() } : {}; + } + if (channel === 'store') { + const row = await this.prisma.storeAccount.findUnique({ + where: { phone: normalized }, + select: { id: true }, + }); + return row ? { accountType: 'STORE', accountId: row.id.toString() } : {}; + } + const row = await this.prisma.partnerAccount.findUnique({ + where: { phone: normalized }, + select: { id: true }, + }); + return row ? { accountType: 'PARTNER', accountId: row.id.toString() } : {}; + } catch (error) { + if (this.noteMissing(error)) return {}; + throw error; + } + } + + classify(method: string, url: string) { + return classifyApiAccessRoute(method, url); + } + + private async adminReady(): Promise { + try { + await this.ensureDefaults(); + } catch (error) { + if (this.noteMissing(error)) { + throw new BadRequestException('api_access_policy 表未就绪,请在 server/dukang-api 执行 npx prisma db push'); + } + throw error; + } + } + + private async ensureDefaults(): Promise { + if (this.ensured) return; + for (const feature of API_ACCESS_PERCENT_FEATURES) { + await this.prisma.apiAccessPolicy.upsert({ + where: { + kind_featureKey_scopeType_actorType_actorId: { + kind: 'percent', + featureKey: feature, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + }, + }, + create: { + kind: 'percent', + featureKey: feature, + scopeType: 'global', + actorType: '', + successPercent: 100, + errorKind: API_ACCESS_DEFAULT_ERROR_KIND, + enabled: true, + }, + update: {}, + }); + } + for (const feature of API_ACCESS_NOTIFY_KEYS) { + await this.prisma.apiAccessPolicy.upsert({ + where: { + kind_featureKey_scopeType_actorType_actorId: { + kind: 'notify', + featureKey: feature, + scopeType: 'global', + actorType: '', + actorId: BigInt(0), + }, + }, + create: { + kind: 'notify', + featureKey: feature, + scopeType: 'global', + actorType: '', + enabled: true, + }, + update: {}, + }); + } + this.ensured = true; + } + + private async loadPercentRules(): Promise { + const cached = this.readCache(); + if (cached) return cached.percents; + await this.ensureDefaults(); + const rows = await this.prisma.apiAccessPolicy.findMany({ where: { kind: 'percent' } }); + const percents = rows.map((row) => this.toRule(row)); + const notifies = await this.readNotifyRows(); + this.cache = { at: Date.now(), percents, notifies }; + return percents; + } + + private async loadNotifyMap(): Promise> { + const cached = this.readCache(); + if (cached) return cached.notifies; + await this.loadPercentRules(); + return this.cache?.notifies ?? new Map(); + } + + private async readNotifyRows(): Promise> { + const rows = await this.prisma.apiAccessPolicy.findMany({ + where: { kind: 'notify', scopeType: 'global' }, + }); + return new Map(rows.map((row) => [row.featureKey, row.enabled])); + } + + private readCache() { + if (!this.cache) return null; + if (Date.now() - this.cache.at > CACHE_MS) return null; + return this.cache; + } + + private invalidate(): void { + this.cache = null; + this.ensured = true; + } + + private toRule(row: PolicyRow): ApiAccessPercentRule { + return { + featureKey: row.featureKey, + scopeType: row.scopeType === 'account' || row.scopeType === 'user' ? row.scopeType : 'global', + actorType: row.actorType, + actorId: row.actorId.toString(), + successPercent: row.successPercent ?? 100, + errorKind: row.errorKind || API_ACCESS_DEFAULT_ERROR_KIND, + }; + } + + private toDto( + row: PolicyRow | undefined, + feature: ApiAccessPercentFeature | null, + labels?: Map, + ): ApiAccessPolicyDto { + const featureKey = row?.featureKey || feature || ''; + return { + id: row?.id.toString() ?? '', + featureKey: featureKey ? featureKey : null, + featureLabel: isApiAccessPercentFeature(featureKey) + ? API_ACCESS_PERCENT_FEATURE_LABELS[featureKey] + : '全部功能', + scopeType: row?.scopeType === 'account' || row?.scopeType === 'user' ? row.scopeType : 'global', + actorType: this.actorTypeOf(row?.actorType), + actorId: row && row.actorId !== BigInt(0) ? row.actorId.toString() : null, + actorLabel: row ? (labels?.get(this.labelKey(row)) ?? null) : null, + successPercent: row?.successPercent ?? 100, + errorKind: isApiAccessErrorKind(row?.errorKind || '') + ? (row?.errorKind as ApiAccessErrorKind) + : API_ACCESS_DEFAULT_ERROR_KIND, + enabled: row?.enabled ?? true, + }; + } + + private toNotifyDto(row: PolicyRow | undefined, feature: (typeof API_ACCESS_NOTIFY_KEYS)[number]): ApiAccessPolicyDto { + return { + id: row?.id.toString() ?? '', + featureKey: feature, + featureLabel: API_ACCESS_NOTIFY_LABELS[feature], + scopeType: 'global', + actorType: null, + actorId: null, + actorLabel: null, + successPercent: null, + errorKind: null, + enabled: row?.enabled ?? true, + }; + } + + private async labelsFor(rows: PolicyRow[]): Promise> { + const ids = (type: string) => rows.filter((row) => row.actorType === type).map((row) => row.actorId); + const load = async (type: string, query: (actorIds: bigint[]) => Promise): Promise => { + const actorIds = ids(type); + return actorIds.length ? query(actorIds) : []; + }; + const [users, stores, partners, hq] = await Promise.all([ + load('USER', (actorIds) => + this.prisma.user.findMany({ + where: { id: { in: actorIds } }, + select: { id: true, phone: true, nickname: true, userNo: true }, + }), + ), + load('STORE', (actorIds) => + this.prisma.storeAccount.findMany({ + where: { id: { in: actorIds } }, + select: { id: true, phone: true, name: true }, + }), + ), + load('PARTNER', (actorIds) => + this.prisma.partnerAccount.findMany({ + where: { id: { in: actorIds } }, + select: { id: true, phone: true, name: true, companyName: true }, + }), + ), + load('HQ', (actorIds) => + this.prisma.hqAccount.findMany({ + where: { id: { in: actorIds } }, + select: { id: true, phone: true, name: true }, + }), + ), + ]); + const map = new Map(); + for (const row of users) { + map.set(`USER:${row.id}`, `用户 ${row.nickname || row.userNo}${row.phone ? `(${row.phone})` : ''}`); + } + for (const row of stores) map.set(`STORE:${row.id}`, `门店 ${row.name}(${row.phone})`); + for (const row of partners) { + map.set(`PARTNER:${row.id}`, `合伙人 ${row.companyName || row.name}(${row.phone})`); + } + for (const row of hq) map.set(`HQ:${row.id}`, `总部 ${row.name}(${row.phone})`); + return map; + } + + private labelKey(row: PolicyRow): string { + return `${row.actorType}:${row.actorId}`; + } + + private actorTypeOf(value: string | undefined): ApiAccessActorType | null { + if (value === 'USER' || value === 'STORE' || value === 'PARTNER' || value === 'HQ') return value; + return null; + } + + private accountHit( + actorType: 'HQ' | 'STORE' | 'PARTNER', + id: bigint, + name: string, + phone: string, + ): ApiAccessActorHit { + return { + actorType, + actorId: id.toString(), + phone, + label: `${name}(${phone})`, + }; + } + + private async accountExists(actorType: string, actorId: bigint): Promise { + if (actorType === 'HQ') { + const row = await this.prisma.hqAccount.findUnique({ where: { id: actorId }, select: { id: true } }); + return !!row; + } + if (actorType === 'STORE') { + const row = await this.prisma.storeAccount.findUnique({ where: { id: actorId }, select: { id: true } }); + return !!row; + } + if (actorType === 'PARTNER') { + const row = await this.prisma.partnerAccount.findUnique({ where: { id: actorId }, select: { id: true } }); + return !!row; + } + return false; + } + + private assertPercent(value: unknown): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) { + throw new BadRequestException('成功百分比须为 0–100 的整数'); + } + return value; + } + + private assertErrorKind(value: unknown): ApiAccessErrorKind { + if (typeof value !== 'string' || !isApiAccessErrorKind(value)) { + throw new BadRequestException('失败文案无效'); + } + if (!(API_ACCESS_ERROR_KINDS as readonly string[]).includes(value)) { + throw new BadRequestException('失败文案无效'); + } + return value; + } + + private noteMissing(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false; + if (error.code !== 'P2021' && error.code !== 'P2022') return false; + if (!this.missingLogged) { + this.missingLogged = true; + this.logger.warn('api_access_policy 表不存在,接口访问限制未生效,请执行 npx prisma db push'); + } + return true; + } +} diff --git a/server/dukang-api/src/common/filters/http-exception.filter.ts b/server/dukang-api/src/common/filters/http-exception.filter.ts index 7f05a41..bfeae73 100644 --- a/server/dukang-api/src/common/filters/http-exception.filter.ts +++ b/server/dukang-api/src/common/filters/http-exception.filter.ts @@ -45,7 +45,10 @@ export class HttpExceptionFilter implements ExceptionFilter { ? res : (res as { message?: string | string[] }).message || exception.message; const msgText = Array.isArray(message) ? message.join(', ') : message; - if (status >= 500) { + const resObj = + typeof res === 'object' && res !== null ? (res as Record) : null; + const apiAccessDenied = resObj?.apiAccessDenied === true; + if (status >= 500 && !apiAccessDenied) { this.alert.notify({ level: 'P0', category: 'api_error', @@ -55,8 +58,6 @@ export class HttpExceptionFilter implements ExceptionFilter { dedupeTtlSec: 120, }); } - const resObj = - typeof res === 'object' && res !== null ? (res as Record) : null; const reason = (resObj?.reason as string | undefined) ?? null; response.status(status).json({ code: status, diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 09a58e5..7edd9ba 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -117,6 +117,7 @@ export const HqOperationAction = { REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT', DEPLOY_TRIGGER: 'DEPLOY_TRIGGER', SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE', + API_ACCESS_UPDATE: 'API_ACCESS_UPDATE', SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV', SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV', WECOM_ALERT_TEST: 'WECOM_ALERT_TEST', @@ -260,6 +261,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回', [HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布', [HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置', + [HqOperationAction.API_ACCESS_UPDATE]: '更新接口访问', [HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件', [HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置', [HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警', diff --git a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts index 21aba43..568f163 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts @@ -20,7 +20,9 @@ import { type WecomPushTemplateDto, type WecomTemplateEventKey, } from '@dukang/shared-types'; +import type { ApiAccessSubject } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; +import { ApiAccessService } from '../../common/api-access/api-access.service'; import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util'; import { WECOM_PUSH_TEMPLATE_DEFAULTS, @@ -62,7 +64,10 @@ type TemplateRow = { export class WecomMessagePushService implements OnModuleInit { private readonly logger = new Logger(WecomMessagePushService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly apiAccess: ApiAccessService, + ) {} async onModuleInit(): Promise { try { @@ -248,12 +253,13 @@ export class WecomMessagePushService implements OnModuleInit { async dispatchEvent( eventKey: WecomTemplateEventKey, vars: Record, - options?: { applyMention?: boolean; handlePath?: string }, + options?: { applyMention?: boolean; handlePath?: string; accessSubject?: ApiAccessSubject }, ): Promise { try { const content = await this.renderEventContent(eventKey, vars, options?.handlePath); return await this.dispatchMarkdown(eventKey, content, { applyMention: options?.applyMention ?? false, + accessSubject: options?.accessSubject, }); } catch (e) { this.logger.warn( @@ -297,8 +303,13 @@ export class WecomMessagePushService implements OnModuleInit { async dispatchMarkdown( eventKey: WecomPushCondition, content: string, - options?: { applyMention?: boolean }, + options?: { applyMention?: boolean; accessSubject?: ApiAccessSubject }, ): Promise { + const gate = await this.apiAccess.gateWecom(eventKey, options?.accessSubject); + if (gate !== 'allow') { + this.logger.log(`skip wecom ${eventKey}: ${gate}`); + return 0; + } const pushes = await this.listMatchingPushes(eventKey); if (!pushes.length) return 0; @@ -456,6 +467,9 @@ export class WecomMessagePushService implements OnModuleInit { sample.vars, sample.handlePath, ); + if (await this.apiAccess.isNotifySwitchOff(eventKey)) { + return { ok: false, message: '接口访问已关闭该通知', preview }; + } const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false }); if (sent === 0) { return { diff --git a/server/dukang-api/src/modules/ops/admin-api-access.controller.ts b/server/dukang-api/src/modules/ops/admin-api-access.controller.ts new file mode 100644 index 0000000..54c80db --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-api-access.controller.ts @@ -0,0 +1,67 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import type { ApiAccessGlobalItem, ApiAccessNotifyItem, ApiAccessOverrideCreate } from '@dukang/shared-types'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { parseBigIntParam } from '../../common/parse-bigint'; +import { ApiAccessService } from '../../common/api-access/api-access.service'; + +@Controller('admin/api-access') +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('api_access') +export class AdminApiAccessController { + constructor(private readonly apiAccess: ApiAccessService) {} + + @Get() + getForm() { + return this.apiAccess.getForm(); + } + + @Get('actors') + searchActors(@Query('scope') scope?: string, @Query('q') q?: string) { + return this.apiAccess.searchActors(scope || '', q || ''); + } + + @Put('globals') + @HqOperation({ + action: HqOperationAction.API_ACCESS_UPDATE, + refType: 'API_ACCESS', + batch: true, + includeBody: true, + }) + updateGlobals(@Body() body: { items?: ApiAccessGlobalItem[] }) { + return this.apiAccess.updateGlobals(body?.items ?? []); + } + + @Put('notifies') + @HqOperation({ + action: HqOperationAction.API_ACCESS_UPDATE, + refType: 'API_ACCESS', + batch: true, + includeBody: true, + }) + updateNotifies(@Body() body: { items?: ApiAccessNotifyItem[] }) { + return this.apiAccess.updateNotifies(body?.items ?? []); + } + + @Post('overrides') + @HqOperation({ + action: HqOperationAction.API_ACCESS_UPDATE, + refType: 'API_ACCESS', + includeBody: true, + }) + createOverride(@Body() body: ApiAccessOverrideCreate) { + return this.apiAccess.createOverride(body); + } + + @Delete('overrides/:id') + @HqOperation({ + action: HqOperationAction.API_ACCESS_UPDATE, + refType: 'API_ACCESS', + refIdField: 'id', + }) + deleteOverride(@Param('id') id: string) { + return this.apiAccess.deleteOverride(parseBigIntParam(id, '规则')); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 594630c..3adba7d 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -921,7 +921,10 @@ export class AdminStoresService { action: '新建', storeId: store.id.toString(), }, - { handlePath: `/stores?storeId=${store.id.toString()}` }, + { + handlePath: `/stores?storeId=${store.id.toString()}`, + accessSubject: { accountType: 'HQ', accountId: actorId.toString() }, + }, ); return this.detailStore(store.id, actorId); diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 7e8e8bf..b85a0a2 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -65,6 +65,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service'; import { AdminDeployController } from './admin-deploy.controller'; import { AdminDeployService } from './admin-deploy.service'; import { AdminSystemConfigController } from './admin-system-config.controller'; +import { AdminApiAccessController } from './admin-api-access.controller'; import { AdminWecomBotsController } from './admin-wecom-bots.controller'; import { AdminWecomBotsService } from './admin-wecom-bots.service'; import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller'; @@ -130,6 +131,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con AdminWechatBindingsController, AdminHqPermissionsController, AdminSystemConfigController, + AdminApiAccessController, AdminWecomBotsController, AdminWecomMessagePushesController, AdminWecomApiPluginsController, diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 114a7cd..4b9c7dc 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -99,6 +99,7 @@ export class StoreService { submitter?: string | null; submitType: '新建' | '重提'; handlePath?: string; + partnerAccountId: bigint; }) { void this.wecomPush.dispatchEvent( 'store.audit_pending', @@ -113,6 +114,7 @@ export class StoreService { { handlePath: opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`, + accessSubject: { accountType: 'PARTNER', accountId: opts.partnerAccountId.toString() }, }, ); } @@ -654,6 +656,7 @@ export class StoreService { submitter, partnerLabel, submitType: '新建', + partnerAccountId, }); } @@ -825,6 +828,7 @@ export class StoreService { submitter, partnerLabel, submitType: '重提', + partnerAccountId, }); } @@ -951,6 +955,7 @@ export class StoreService { submitter, partnerLabel, submitType: '重提', + partnerAccountId, }); }