merge(dev): 接口访问配置
CI / verify (push) Waiting to run

This commit is contained in:
2026-09-26 12:05:00 +08:00
29 changed files with 1888 additions and 18 deletions
+2
View File
@@ -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() {
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/api-access" element={<ApiAccessPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
@@ -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: <LockOutlined />, label: '权限分配' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/api-access', icon: <ApiOutlined />, label: '接口访问' },
{ key: '/system-settings', icon: <SettingOutlined />, 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',
};
+426
View File
@@ -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<PercentDraft[]>([]);
const [overrides, setOverrides] = useState<ApiAccessPolicyDto[]>([]);
const [notifies, setNotifies] = useState<ApiAccessPolicyDto[]>([]);
const [open, setOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await request<ApiAccessFormResponse>('/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<ApiAccessFormResponse>('/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<ApiAccessFormResponse>('/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<ApiAccessFormResponse>(`/admin/api-access/overrides/${id}`, {
method: 'DELETE',
});
setOverrides(data.overrides);
message.success('已删除');
} catch (error) {
message.error(error instanceof Error ? error.message : '删除失败');
}
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Typography.Title level={4} style={{ marginBottom: 8 }}>
接口访问
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
成功百分比是放行比例,默认 100。0 为全部拒绝,每次请求单独计算。可按用户或账户覆盖。总部登录不受登录百分比影响。被拒绝的请求只返回所选文案,不发企微。通知开关只停止对应企微消息,不拦截下单、核销、出账、审核和改套餐。
</Typography.Paragraph>
</div>
<Card
title="功能成功百分比"
extra={
<Button type="primary" loading={savingGlobals} onClick={() => void saveGlobals()}>
保存
</Button>
}
>
<Table
rowKey="featureKey"
loading={loading}
pagination={false}
dataSource={percents}
columns={[
{ title: '功能', dataIndex: 'featureLabel', width: 140 },
{
title: '成功百分比',
width: 180,
render: (_, row) => (
<InputNumber
min={0}
max={100}
precision={0}
value={row.successPercent}
onChange={(value) =>
setPercents((list) =>
list.map((item) =>
item.featureKey === row.featureKey
? { ...item, successPercent: typeof value === 'number' ? value : 100 }
: item,
),
)
}
/>
),
},
{
title: '失败文案',
render: (_, row) => (
<Select
style={{ width: 180 }}
value={row.errorKind}
options={ERROR_OPTIONS}
onChange={(errorKind: ApiAccessErrorKind) =>
setPercents((list) =>
list.map((item) => (item.featureKey === row.featureKey ? { ...item, errorKind } : item)),
)
}
/>
),
},
{
title: '说明',
render: (_, row) =>
row.featureKey === 'audit_notify'
? '不拦截审核,只按比例丢弃门店审核通知'
: row.featureKey === 'login'
? 'C 端、门店、合伙人登录;总部登录始终放行'
: '命中后直接返回失败文案',
},
]}
/>
</Card>
<Card
title="账户 / 用户覆盖"
extra={
<Button type="primary" onClick={() => setOpen(true)}>
添加
</Button>
}
>
<Table
rowKey="id"
loading={loading}
pagination={false}
dataSource={overrides}
locale={{ emptyText: '未设置覆盖,全部走上方的功能百分比' }}
columns={[
{
title: '范围',
width: 100,
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) => (
<Popconfirm title="删除这条覆盖?" onConfirm={() => void removeOverride(row.id)}>
<Button type="link" danger>
删除
</Button>
</Popconfirm>
),
},
]}
/>
</Card>
<Card
title="通知开关"
extra={
<Button type="primary" loading={savingNotifies} onClick={() => void saveNotifies()}>
保存
</Button>
}
>
<Table
rowKey="featureKey"
loading={loading}
pagination={false}
dataSource={notifies}
columns={[
{ title: '通知', dataIndex: 'featureLabel' },
{
title: '发送企微',
width: 160,
render: (_, row) => (
<Switch
checked={row.enabled}
checkedChildren="开"
unCheckedChildren="关"
onChange={(enabled) =>
setNotifies((list) =>
list.map((item) => (item.featureKey === row.featureKey ? { ...item, enabled } : item)),
)
}
/>
),
},
]}
/>
</Card>
<OverrideModal
open={open}
onClose={() => setOpen(false)}
onCreated={(data) => {
setOverrides(data.overrides);
setOpen(false);
}}
/>
</Space>
);
}
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<ApiAccessActorHit[]>([]);
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<ApiAccessActorHit[]>(
`/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<ApiAccessFormResponse>('/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 (
<Modal
title="添加覆盖"
open={open}
onCancel={onClose}
onOk={() => void submit()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="scopeType" label="范围" rules={[{ required: true }]}>
<Select
options={[
{ value: 'user', label: '用户' },
{ value: 'account', label: '账户' },
]}
onChange={(value: 'user' | 'account') => {
setScope(value);
setHits([]);
form.setFieldValue('actor', undefined);
}}
/>
</Form.Item>
<Form.Item name="actor" label="对象" rules={[{ required: true, message: '请选择对象' }]}>
<Select
showSearch
filterOption={false}
placeholder={scope === 'user' ? '手机号、昵称或用户编号' : '手机号或名称'}
loading={searching}
onSearch={(q) => void onSearch(q)}
options={hits.map((hit) => ({
value: `${hit.actorType}:${hit.actorId}`,
label: hit.label,
}))}
/>
</Form.Item>
<Form.Item name="featureKey" label="功能">
<Select options={FEATURE_OPTIONS} />
</Form.Item>
<Form.Item name="successPercent" label="成功百分比" rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="errorKind" label="失败文案" rules={[{ required: true }]}>
<Select options={ERROR_OPTIONS} />
</Form.Item>
</Form>
</Modal>
);
}
+2
View File
@@ -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 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
+28 -5
View File
@@ -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 端/门店端活动图;预生成每人缓存图。
+4 -2
View File
@@ -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 接口访问。登录/商品加载/门店加载/门店提交按成功百分比放行;审核通知按百分比丢弃企微;订单/核销/账单/门店审核/套餐修改只做通知总开关。总部登录不参与 |
+55
View File
@@ -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 时,审核写入成功,通知按比例丢弃
+1 -1
View File
@@ -143,7 +143,7 @@ HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下
## 16. 系统设置
HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。
HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。接口访问(`/api-access`,权限 `api_access`):按功能/账户/用户设成功百分比;订单/核销/账单/门店审核/套餐修改只关企微通知。
## 17. 企业微信
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import {
allowBySuccessPercent,
classifyApiAccessRoute,
decideWecomSend,
resolveApiAccessPercent,
type ApiAccessPercentRule,
} from './api-access';
function rule(partial: Partial<ApiAccessPercentRule> & Pick<ApiAccessPercentRule, 'scopeType' | 'featureKey'>): 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');
});
});
+161
View File
@@ -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';
}
+1
View File
@@ -416,3 +416,4 @@ export * from './wecom-report';
export * from './wecom-plugin';
export * from './shipping-address';
export * from './store-address';
export * from './api-access';
+145
View File
@@ -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<ApiAccessPercentFeature, string> = {
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<ApiAccessNotifyKey, string> = {
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<string, ApiAccessNotifyKey> = {
'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<ApiAccessErrorKind, string> = {
network_load_failed: '网络加载失败',
request_error: '请求异常',
illegal_access: '非法访问',
wechat_service_error: '微信服务异常',
};
export const API_ACCESS_ERROR_STATUS: Record<ApiAccessErrorKind, number> = {
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);
}
+3 -1
View File
@@ -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',
);
}
+1
View File
@@ -36,3 +36,4 @@ export * from './llm-config';
export * from './knowledge-base';
export * from './legal';
export * from './dev-plan';
export * from './api-access';
+1 -1
View File
@@ -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)。
+19
View File
@@ -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
+2
View File
@@ -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,
@@ -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],
})
@@ -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],
);
}
}
@@ -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<Observable<unknown>> {
if (context.getType() !== 'http') return next.handle();
const req = context.switchToHttp().getRequest<AccessRequest>();
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<ApiAccessSubject> {
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 {};
}
@@ -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 {}
@@ -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<string, boolean> } | null =
null;
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
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<ApiAccessFormResponse> {
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<ApiAccessFormResponse> {
await this.adminReady();
if (!Array.isArray(items) || items.length !== API_ACCESS_PERCENT_FEATURES.length) {
throw new BadRequestException('需同时提交五个功能的成功百分比');
}
const seen = new Set<string>();
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<ApiAccessFormResponse> {
await this.adminReady();
if (!Array.isArray(items) || items.length !== API_ACCESS_NOTIFY_KEYS.length) {
throw new BadRequestException('需同时提交全部通知开关');
}
const seen = new Set<string>();
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<ApiAccessFormResponse> {
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<ApiAccessFormResponse> {
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<ApiAccessActorHit[]> {
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<boolean> {
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<WecomDispatchGate> {
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<ApiAccessSubject> {
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<void> {
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<void> {
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<ApiAccessPercentRule[]> {
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<Map<string, boolean>> {
const cached = this.readCache();
if (cached) return cached.notifies;
await this.loadPercentRules();
return this.cache?.notifies ?? new Map();
}
private async readNotifyRows(): Promise<Map<string, boolean>> {
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<string, string>,
): 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<Map<string, string>> {
const ids = (type: string) => rows.filter((row) => row.actorType === type).map((row) => row.actorId);
const load = async <T>(type: string, query: (actorIds: bigint[]) => Promise<T[]>): Promise<T[]> => {
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<string, string>();
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<boolean> {
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;
}
}
@@ -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<string, unknown>) : 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<string, unknown>) : null;
const reason = (resObj?.reason as string | undefined) ?? null;
response.status(status).json({
code: status,
@@ -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<string, string> = {
[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]: '测试企微运营告警',
@@ -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<void> {
try {
@@ -248,12 +253,13 @@ export class WecomMessagePushService implements OnModuleInit {
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
options?: { applyMention?: boolean; handlePath?: string; accessSubject?: ApiAccessSubject },
): Promise<number> {
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<number> {
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 {
@@ -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, '规则'));
}
}
@@ -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);
@@ -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,
@@ -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,
});
}