feat(ops): 总部可按百分比限制接口放行并开关企微通知
线上需要按账户、用户和功能控制登录与加载成功率,同时单独停发订单、核销和账单通知。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -49,6 +49,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
|||||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
|
import ApiAccessPage from './pages/ApiAccessPage';
|
||||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
|
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
|
||||||
@@ -155,6 +156,7 @@ export default function App() {
|
|||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||||
|
<Route path="/api-access" element={<ApiAccessPage />} />
|
||||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
ApiOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
@@ -164,6 +165,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
|
{ key: '/api-access', icon: <ApiOutlined />, label: '接口访问' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -240,6 +242,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/logs/domain-events': 'logs',
|
'/logs/domain-events': 'logs',
|
||||||
'/hq-permissions': 'hq_permissions',
|
'/hq-permissions': 'hq_permissions',
|
||||||
'/test-whitelist': 'test_whitelist',
|
'/test-whitelist': 'test_whitelist',
|
||||||
|
'/api-access': 'api_access',
|
||||||
'/system-settings': 'system_settings_any',
|
'/system-settings': 'system_settings_any',
|
||||||
'/hq-accounts': 'hq_accounts',
|
'/hq-accounts': 'hq_accounts',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.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.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 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||||
|
|||||||
+28
-5
@@ -1,9 +1,8 @@
|
|||||||
# 杜康好客 · V4 PRD
|
# 杜康好客 · 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** 推广码渠道负责人/关联合伙人
|
> **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 概览、财务银行账户)。
|
> 未改规则仍见 [`杜康好客-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.0.21 开发文档`](./杜康好客-v4.0.21-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
> 实现:[`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)
|
|
||||||
|
|
||||||
## 0. 版本
|
## 0. 版本
|
||||||
|
|
||||||
@@ -18,6 +17,7 @@
|
|||||||
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
| 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.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.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,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -133,6 +133,29 @@ HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查
|
|||||||
- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
|
- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
|
||||||
- 权限:`finance`。
|
- 权限:`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 端/门店端活动图;预生成每人缓存图。
|
不把推广码改成关联码、不回刷历史绑定;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.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);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.20 | [`推广码渠道负责人 + 关联合伙人`](./杜康好客-v4.0.20-开发文档.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-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
|
||||||
| 2026-09-16 | v4.0.20:推广码渠道负责人改为可多选主合伙人(H5 只看扫码/归因/订单数);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 |
|
| 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-17 | v4.0.20:PromoModule 改走 CityScope 避免 Nest 循环依赖;本地 Vite 代理默认 `127.0.0.1:3010` |
|
||||||
|
| 2026-09-26 | v4.0.21:HQ 接口访问。登录/商品加载/门店加载/门店提交按成功百分比放行;审核通知按百分比丢弃企微;订单/核销/账单/门店审核/套餐修改只做通知总开关。总部登录不参与 |
|
||||||
|
|||||||
@@ -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
@@ -143,7 +143,7 @@ HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下
|
|||||||
|
|
||||||
## 16. 系统设置
|
## 16. 系统设置
|
||||||
|
|
||||||
HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。
|
HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。接口访问(`/api-access`,权限 `api_access`):按功能/账户/用户设成功百分比;订单/核销/账单/门店审核/套餐修改只关企微通知。
|
||||||
|
|
||||||
## 17. 企业微信
|
## 17. 企业微信
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -416,3 +416,4 @@ export * from './wecom-report';
|
|||||||
export * from './wecom-plugin';
|
export * from './wecom-plugin';
|
||||||
export * from './shipping-address';
|
export * from './shipping-address';
|
||||||
export * from './store-address';
|
export * from './store-address';
|
||||||
|
export * from './api-access';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||||
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||||
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
|
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
|
||||||
|
{ key: 'api_access', label: '接口访问', group: '系统设置' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -76,6 +77,7 @@ export const HQ_DANGEROUS_PERMISSION_KEYS = [
|
|||||||
'orders_delete',
|
'orders_delete',
|
||||||
'cities_delete',
|
'cities_delete',
|
||||||
'store_categories_delete',
|
'store_categories_delete',
|
||||||
|
'api_access',
|
||||||
] as const satisfies readonly HqPermissionKey[];
|
] as const satisfies readonly HqPermissionKey[];
|
||||||
|
|
||||||
export function isHqDangerousPermission(key: string): boolean {
|
export function isHqDangerousPermission(key: string): boolean {
|
||||||
@@ -175,7 +177,7 @@ const OPS_STORE_KEYS: HqPermissionKey[] = [
|
|||||||
/** 开发者默认:除权限分配、HQ 账户外全部权限 */
|
/** 开发者默认:除权限分配、HQ 账户外全部权限 */
|
||||||
function developerDefaultPermissions(): HqPermissionKey[] {
|
function developerDefaultPermissions(): HqPermissionKey[] {
|
||||||
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter(
|
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',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ export * from './llm-config';
|
|||||||
export * from './knowledge-base';
|
export * from './knowledge-base';
|
||||||
export * from './legal';
|
export * from './legal';
|
||||||
export * from './dev-plan';
|
export * from './dev-plan';
|
||||||
|
export * from './api-access';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
|
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
|
||||||
| **ops** | 只读聚合、ActivityPoster | jacy-dukang |
|
| **ops** | 只读聚合、ActivityPoster | jacy-dukang |
|
||||||
| **analytics** | LogUserAnalytics | jacy-dukang |
|
| **analytics** | LogUserAnalytics | jacy-dukang |
|
||||||
| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
|
| **common** | CommonResource, CommonEvent, CommonTicket, ApiAccessPolicy | jacy-dukang |
|
||||||
| **integrations** | 无表 | jacy-dukang |
|
| **integrations** | 无表 | jacy-dukang |
|
||||||
|
|
||||||
**log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
|
**log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
|
||||||
|
|||||||
@@ -469,6 +469,25 @@ model SystemConfig {
|
|||||||
@@map("system_config")
|
@@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 可创建多实例,长连接)
|
/// 企业微信智能机器人(HQ 可创建多实例,长连接)
|
||||||
model WecomBot {
|
model WecomBot {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
|||||||
import { CommonModule } from './modules/common/common.module';
|
import { CommonModule } from './modules/common/common.module';
|
||||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||||
import { SystemConfigModule } from './common/system-config/system-config.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 { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
|
||||||
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
|
||||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||||
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
ApiAccessModule,
|
||||||
SystemConfigModule,
|
SystemConfigModule,
|
||||||
TestWhitelistModule,
|
TestWhitelistModule,
|
||||||
GeoModule,
|
GeoModule,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
|
import { ApiAccessModule } from '../api-access/api-access.module';
|
||||||
import { AlertService } from './alert.service';
|
import { AlertService } from './alert.service';
|
||||||
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
|
import { PayRedeemAnomalyService } from './pay-redeem-anomaly.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
@@ -9,7 +10,7 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-
|
|||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule, ApiAccessModule],
|
||||||
providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
|
providers: [WecomMessagePushService, AlertService, PayRedeemAnomalyService],
|
||||||
exports: [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
|
||||||
: (res as { message?: string | string[] }).message || exception.message;
|
: (res as { message?: string | string[] }).message || exception.message;
|
||||||
const msgText = Array.isArray(message) ? message.join(', ') : 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({
|
this.alert.notify({
|
||||||
level: 'P0',
|
level: 'P0',
|
||||||
category: 'api_error',
|
category: 'api_error',
|
||||||
@@ -55,8 +58,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
dedupeTtlSec: 120,
|
dedupeTtlSec: 120,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const resObj =
|
|
||||||
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
|
|
||||||
const reason = (resObj?.reason as string | undefined) ?? null;
|
const reason = (resObj?.reason as string | undefined) ?? null;
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
code: status,
|
code: status,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const HqOperationAction = {
|
|||||||
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
|
||||||
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
|
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
|
||||||
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
|
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
|
||||||
|
API_ACCESS_UPDATE: 'API_ACCESS_UPDATE',
|
||||||
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
|
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
|
||||||
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
|
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
|
||||||
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
|
WECOM_ALERT_TEST: 'WECOM_ALERT_TEST',
|
||||||
@@ -260,6 +261,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
|
||||||
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
|
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
|
||||||
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
|
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
|
||||||
|
[HqOperationAction.API_ACCESS_UPDATE]: '更新接口访问',
|
||||||
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
|
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
|
||||||
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
|
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
|
||||||
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
|
[HqOperationAction.WECOM_ALERT_TEST]: '测试企微运营告警',
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
type WecomPushTemplateDto,
|
type WecomPushTemplateDto,
|
||||||
type WecomTemplateEventKey,
|
type WecomTemplateEventKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
import type { ApiAccessSubject } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
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 { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||||
import {
|
import {
|
||||||
WECOM_PUSH_TEMPLATE_DEFAULTS,
|
WECOM_PUSH_TEMPLATE_DEFAULTS,
|
||||||
@@ -62,7 +64,10 @@ type TemplateRow = {
|
|||||||
export class WecomMessagePushService implements OnModuleInit {
|
export class WecomMessagePushService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(WecomMessagePushService.name);
|
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> {
|
async onModuleInit(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -248,12 +253,13 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
async dispatchEvent(
|
async dispatchEvent(
|
||||||
eventKey: WecomTemplateEventKey,
|
eventKey: WecomTemplateEventKey,
|
||||||
vars: Record<string, string | number | null | undefined>,
|
vars: Record<string, string | number | null | undefined>,
|
||||||
options?: { applyMention?: boolean; handlePath?: string },
|
options?: { applyMention?: boolean; handlePath?: string; accessSubject?: ApiAccessSubject },
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
|
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
|
||||||
return await this.dispatchMarkdown(eventKey, content, {
|
return await this.dispatchMarkdown(eventKey, content, {
|
||||||
applyMention: options?.applyMention ?? false,
|
applyMention: options?.applyMention ?? false,
|
||||||
|
accessSubject: options?.accessSubject,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -297,8 +303,13 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
async dispatchMarkdown(
|
async dispatchMarkdown(
|
||||||
eventKey: WecomPushCondition,
|
eventKey: WecomPushCondition,
|
||||||
content: string,
|
content: string,
|
||||||
options?: { applyMention?: boolean },
|
options?: { applyMention?: boolean; accessSubject?: ApiAccessSubject },
|
||||||
): Promise<number> {
|
): 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);
|
const pushes = await this.listMatchingPushes(eventKey);
|
||||||
if (!pushes.length) return 0;
|
if (!pushes.length) return 0;
|
||||||
|
|
||||||
@@ -456,6 +467,9 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
sample.vars,
|
sample.vars,
|
||||||
sample.handlePath,
|
sample.handlePath,
|
||||||
);
|
);
|
||||||
|
if (await this.apiAccess.isNotifySwitchOff(eventKey)) {
|
||||||
|
return { ok: false, message: '接口访问已关闭该通知', preview };
|
||||||
|
}
|
||||||
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
|
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
|
||||||
if (sent === 0) {
|
if (sent === 0) {
|
||||||
return {
|
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: '新建',
|
action: '新建',
|
||||||
storeId: store.id.toString(),
|
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);
|
return this.detailStore(store.id, actorId);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
|||||||
import { AdminDeployController } from './admin-deploy.controller';
|
import { AdminDeployController } from './admin-deploy.controller';
|
||||||
import { AdminDeployService } from './admin-deploy.service';
|
import { AdminDeployService } from './admin-deploy.service';
|
||||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||||
|
import { AdminApiAccessController } from './admin-api-access.controller';
|
||||||
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
import { AdminWecomBotsController } from './admin-wecom-bots.controller';
|
||||||
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
import { AdminWecomBotsService } from './admin-wecom-bots.service';
|
||||||
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
|
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
|
||||||
@@ -130,6 +131,7 @@ import { PartnerActivityPostersController } from './partner-activity-posters.con
|
|||||||
AdminWechatBindingsController,
|
AdminWechatBindingsController,
|
||||||
AdminHqPermissionsController,
|
AdminHqPermissionsController,
|
||||||
AdminSystemConfigController,
|
AdminSystemConfigController,
|
||||||
|
AdminApiAccessController,
|
||||||
AdminWecomBotsController,
|
AdminWecomBotsController,
|
||||||
AdminWecomMessagePushesController,
|
AdminWecomMessagePushesController,
|
||||||
AdminWecomApiPluginsController,
|
AdminWecomApiPluginsController,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export class StoreService {
|
|||||||
submitter?: string | null;
|
submitter?: string | null;
|
||||||
submitType: '新建' | '重提';
|
submitType: '新建' | '重提';
|
||||||
handlePath?: string;
|
handlePath?: string;
|
||||||
|
partnerAccountId: bigint;
|
||||||
}) {
|
}) {
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'store.audit_pending',
|
'store.audit_pending',
|
||||||
@@ -113,6 +114,7 @@ export class StoreService {
|
|||||||
{
|
{
|
||||||
handlePath:
|
handlePath:
|
||||||
opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
|
opts.handlePath || `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
|
||||||
|
accessSubject: { accountType: 'PARTNER', accountId: opts.partnerAccountId.toString() },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,6 +656,7 @@ export class StoreService {
|
|||||||
submitter,
|
submitter,
|
||||||
partnerLabel,
|
partnerLabel,
|
||||||
submitType: '新建',
|
submitType: '新建',
|
||||||
|
partnerAccountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,6 +828,7 @@ export class StoreService {
|
|||||||
submitter,
|
submitter,
|
||||||
partnerLabel,
|
partnerLabel,
|
||||||
submitType: '重提',
|
submitType: '重提',
|
||||||
|
partnerAccountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +955,7 @@ export class StoreService {
|
|||||||
submitter,
|
submitter,
|
||||||
partnerLabel,
|
partnerLabel,
|
||||||
submitType: '重提',
|
submitType: '重提',
|
||||||
|
partnerAccountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user