1
This commit is contained in:
@@ -4,13 +4,15 @@
|
||||
|
||||
## 套餐统计
|
||||
|
||||
| 套餐 | 接口数 |
|
||||
| 套餐 | 接口数量 |
|
||||
|---|---:|
|
||||
| 初级版 | 68 |
|
||||
| 高级版 | 79 |
|
||||
| 定制版 | 37 |
|
||||
| 白标/OEM | 9 |
|
||||
|
||||
> 上表「接口数量」之和为 **193**(与文首接口总数一致)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 登录(9 个接口)
|
||||
|
||||
@@ -8,16 +8,18 @@
|
||||
|
||||
## 2. 套餐统计
|
||||
|
||||
| 套餐 | 接口数 |
|
||||
| 套餐 | 接口数量 |
|
||||
|---|---:|
|
||||
| 初级版 | 68 |
|
||||
| 高级版 | 79 |
|
||||
| 定制版 | 37 |
|
||||
| 白标/OEM | 9 |
|
||||
|
||||
> 上表「接口数量」之和为 **193**,与 §1 接口总数一致(每个 `Method + Path` 仅归属一个套餐)。
|
||||
|
||||
## 3. 按模块统计
|
||||
|
||||
| 模块 | 接口数 | 初级版 | 高级版 | 定制版 | 白标/OEM |
|
||||
| 模块 | 接口数量 | 初级版 | 高级版 | 定制版 | 白标/OEM |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 企业微信 | 22 | 0 | 0 | 22 | 0 |
|
||||
| 公众号/小程序 | 13 | 0 | 13 | 0 | 0 |
|
||||
@@ -37,6 +39,9 @@
|
||||
| 群管理 | 20 | 11 | 9 | 0 | 0 |
|
||||
| 视频号 | 4 | 0 | 4 | 0 | 0 |
|
||||
| 设备 | 4 | 4 | 0 | 0 | 0 |
|
||||
| **合计** | **193** | **68** | **79** | **37** | **9** |
|
||||
|
||||
> **说明**:每行「接口数量」等于该行四个套餐列之和;合计行与 §1、§2 一致。
|
||||
|
||||
## 4. 全量接口明细
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ class PlanServer {
|
||||
async exportRows(row) {
|
||||
return window.framework.http.post("/biz_plan/export", row);
|
||||
}
|
||||
|
||||
async proxyApiCatalog() {
|
||||
return window.framework.http.post("/biz_plan/proxy_api_catalog", {});
|
||||
}
|
||||
}
|
||||
|
||||
export default new PlanServer();
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="720" :loading="saving" @on-ok="save">
|
||||
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="900" :loading="saving" @on-ok="save">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="120">
|
||||
<FormItem label="套餐编码" prop="plan_code">
|
||||
<Input v-model="form.plan_code" :disabled="!!form.id" />
|
||||
@@ -83,8 +83,58 @@
|
||||
<FormItem label="功能点 JSON">
|
||||
<Input v-model="featuresText" type="textarea" :rows="4" placeholder='如 {"msg":true} 或 ["msg","mass"]' />
|
||||
</FormItem>
|
||||
<FormItem label="可用接口 JSON">
|
||||
<Input v-model="allowedApisText" type="textarea" :rows="4" placeholder='接口路径数组,如 ["/user/GetProfile","/message/SendText"],留空=不限制' />
|
||||
<FormItem label="API 权限">
|
||||
<RadioGroup v-model="api_permission_mode">
|
||||
<Radio label="all">不限制(Swagger 内转发接口均可调用)</Radio>
|
||||
<Radio label="whitelist">仅允许勾选的接口路径(白名单)</Radio>
|
||||
</RadioGroup>
|
||||
<p class="form-hint">白名单与鉴权路径一致(如 /user/GetProfile),目录来自 swagger.json。</p>
|
||||
</FormItem>
|
||||
<FormItem v-if="api_permission_mode === 'whitelist'" label=" ">
|
||||
<Alert v-if="selected_api_paths.length === 0" type="warning" show-icon class="mb12">
|
||||
未勾选任何接口时,保存后该套餐将无法调用任何转发 API。
|
||||
</Alert>
|
||||
<div v-if="proxy_catalog.tags.length === 0" class="text-muted">暂无接口目录(请检查 swagger),可联系后台配置。</div>
|
||||
<div v-else class="api-catalog-wrap">
|
||||
<div class="api-catalog-toolbar">
|
||||
<Button type="default" size="small" @click="select_all_catalog">全选</Button>
|
||||
<Button type="default" size="small" class="ml8" @click="clear_all_catalog">全不选</Button>
|
||||
<span class="api-catalog-stats ml10">已选 {{ selected_api_paths.length }} / {{ catalog_path_count }}</span>
|
||||
</div>
|
||||
<Collapse v-model="api_collapse">
|
||||
<Panel v-for="tag in proxy_catalog.tags" :key="tag" :name="tag">
|
||||
{{ tag }}({{ count_selected_in_tag(tag) }}/{{ (proxy_catalog.groups[tag] || []).length }})
|
||||
<div slot="content">
|
||||
<div class="panel-actions mb8">
|
||||
<Button type="text" size="small" @click="select_all_in_tag(tag)">本组全选</Button>
|
||||
<Button type="text" size="small" @click="clear_tag(tag)">本组清空</Button>
|
||||
</div>
|
||||
<div class="api-check-grid">
|
||||
<Checkbox
|
||||
v-for="it in (proxy_catalog.groups[tag] || [])"
|
||||
:key="it.path"
|
||||
:value="selected_api_paths.includes(it.path)"
|
||||
class="api-check-item"
|
||||
@on-change="(v) => set_api_path_checked(it.path, v)"
|
||||
>
|
||||
<span class="api-path-text">{{ it.path }}</span>
|
||||
<span class="api-meta">{{ it.methods.join(', ') }}</span>
|
||||
<span v-if="it.summary" class="api-summary">{{ it.summary }}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<div v-if="orphan_api_paths.length" class="orphan-apis mt12">
|
||||
<p class="sub-label">以下路径不在当前目录中(仍会计入白名单,可点 × 移除)</p>
|
||||
<Tag
|
||||
v-for="p in orphan_api_paths"
|
||||
:key="p"
|
||||
closable
|
||||
@on-close="() => remove_orphan_path(p)"
|
||||
>{{ p }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="状态" prop="status">
|
||||
<Select v-model="form.status" style="width: 100%">
|
||||
@@ -115,7 +165,10 @@ export default {
|
||||
saving: false,
|
||||
form: {},
|
||||
featuresText: '{}',
|
||||
allowedApisText: '',
|
||||
api_permission_mode: 'all',
|
||||
selected_api_paths: [],
|
||||
proxy_catalog: { items: [], groups: {}, tags: [] },
|
||||
api_collapse: [],
|
||||
rules: {
|
||||
plan_code: [{ required: true, message: '必填', trigger: 'blur' }],
|
||||
plan_name: [{ required: true, message: '必填', trigger: 'blur' }],
|
||||
@@ -124,6 +177,15 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
catalog_path_set() {
|
||||
return new Set((this.proxy_catalog.items || []).map((x) => x.path))
|
||||
},
|
||||
catalog_path_count() {
|
||||
return (this.proxy_catalog.items || []).length
|
||||
},
|
||||
orphan_api_paths() {
|
||||
return this.selected_api_paths.filter((p) => !this.catalog_path_set.has(p))
|
||||
},
|
||||
columns() {
|
||||
return [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
@@ -131,6 +193,26 @@ export default {
|
||||
{ title: '名称', key: 'plan_name', minWidth: 140 },
|
||||
{ title: '月费', key: 'monthly_price', width: 90 },
|
||||
{ title: 'API调用上限', key: 'api_call_quota', width: 120, render: (h, p) => h('span', p.row.api_call_quota > 0 ? p.row.api_call_quota : '不限') },
|
||||
{
|
||||
title: 'API权限',
|
||||
key: 'api_perm',
|
||||
width: 108,
|
||||
render: (h, p) => {
|
||||
const a = p.row.allowed_apis
|
||||
if (a == null || a === '') return h('span', '不限制')
|
||||
let list = a
|
||||
if (typeof list === 'string') {
|
||||
try {
|
||||
list = JSON.parse(list)
|
||||
} catch (e) {
|
||||
return h('span', '—')
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return h('span', '—')
|
||||
if (list.length === 0) return h('span', { class: 'text-danger' }, '全禁')
|
||||
return h('span', `${list.length} 项`)
|
||||
},
|
||||
},
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -174,8 +256,66 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.load(1)
|
||||
this.load_proxy_catalog()
|
||||
},
|
||||
methods: {
|
||||
async load_proxy_catalog() {
|
||||
try {
|
||||
const res = await planServer.proxyApiCatalog()
|
||||
if (res && res.code === 0 && res.data) {
|
||||
this.proxy_catalog = {
|
||||
items: res.data.items || [],
|
||||
groups: res.data.groups || {},
|
||||
tags: res.data.tags || [],
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.$Message.error('加载转发接口目录失败')
|
||||
}
|
||||
},
|
||||
normalize_allowed_apis_raw(raw) {
|
||||
if (raw == null || raw === '') return []
|
||||
let list = raw
|
||||
if (typeof list === 'string') {
|
||||
try {
|
||||
list = JSON.parse(list)
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return Array.isArray(list) ? [...list] : []
|
||||
},
|
||||
count_selected_in_tag(tag) {
|
||||
const paths = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
|
||||
return this.selected_api_paths.filter((p) => paths.has(p)).length
|
||||
},
|
||||
set_api_path_checked(path, checked) {
|
||||
const set = new Set(this.selected_api_paths)
|
||||
if (checked) set.add(path)
|
||||
else set.delete(path)
|
||||
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
},
|
||||
select_all_in_tag(tag) {
|
||||
const set = new Set(this.selected_api_paths)
|
||||
for (const it of this.proxy_catalog.groups[tag] || []) set.add(it.path)
|
||||
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
},
|
||||
clear_tag(tag) {
|
||||
const rm = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
|
||||
this.selected_api_paths = this.selected_api_paths.filter((p) => !rm.has(p))
|
||||
},
|
||||
select_all_catalog() {
|
||||
const set = new Set(this.selected_api_paths)
|
||||
for (const it of this.proxy_catalog.items || []) set.add(it.path)
|
||||
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
},
|
||||
clear_all_catalog() {
|
||||
const keep = new Set(this.orphan_api_paths)
|
||||
this.selected_api_paths = Array.from(keep).sort((a, b) => a.localeCompare(b))
|
||||
},
|
||||
remove_orphan_path(path) {
|
||||
this.selected_api_paths = this.selected_api_paths.filter((p) => p !== path)
|
||||
},
|
||||
async load(page) {
|
||||
if (page) this.param.pageOption.page = page
|
||||
const res = await planServer.page({ param: this.param })
|
||||
@@ -203,12 +343,13 @@ export default {
|
||||
: typeof row.enabled_features === 'string'
|
||||
? row.enabled_features
|
||||
: JSON.stringify(row.enabled_features, null, 2)
|
||||
this.allowedApisText =
|
||||
row.allowed_apis == null
|
||||
? ''
|
||||
: typeof row.allowed_apis === 'string'
|
||||
? row.allowed_apis
|
||||
: JSON.stringify(row.allowed_apis, null, 2)
|
||||
if (row.allowed_apis == null || row.allowed_apis === '') {
|
||||
this.api_permission_mode = 'all'
|
||||
this.selected_api_paths = []
|
||||
} else {
|
||||
this.api_permission_mode = 'whitelist'
|
||||
this.selected_api_paths = this.normalize_allowed_apis_raw(row.allowed_apis)
|
||||
}
|
||||
} else {
|
||||
this.form = {
|
||||
plan_code: '',
|
||||
@@ -225,8 +366,10 @@ export default {
|
||||
status: 'active',
|
||||
}
|
||||
this.featuresText = '{}'
|
||||
this.allowedApisText = ''
|
||||
this.api_permission_mode = 'all'
|
||||
this.selected_api_paths = []
|
||||
}
|
||||
this.api_collapse = this.proxy_catalog.tags.length ? [...this.proxy_catalog.tags] : []
|
||||
this.modal = true
|
||||
},
|
||||
save() {
|
||||
@@ -248,20 +391,8 @@ export default {
|
||||
}
|
||||
}
|
||||
let allowed_apis = null
|
||||
const apisStr = (this.allowedApisText || '').trim()
|
||||
if (apisStr) {
|
||||
try {
|
||||
allowed_apis = JSON.parse(apisStr)
|
||||
if (!Array.isArray(allowed_apis)) {
|
||||
this.$Message.error('可用接口必须是 JSON 数组')
|
||||
this.saving = false
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
this.$Message.error('可用接口 JSON 格式错误')
|
||||
this.saving = false
|
||||
return
|
||||
}
|
||||
if (this.api_permission_mode === 'whitelist') {
|
||||
allowed_apis = [...this.selected_api_paths]
|
||||
}
|
||||
const payload = { ...this.form, enabled_features, allowed_apis }
|
||||
try {
|
||||
@@ -343,4 +474,79 @@ export default {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ed4014;
|
||||
}
|
||||
|
||||
.mb12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mt12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.api-catalog-wrap {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.api-catalog-toolbar {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.api-catalog-stats {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-actions .ivu-btn-text + .ivu-btn-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.api-check-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.api-path-text {
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.api-meta {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.api-summary {
|
||||
color: #515a6e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-label {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.orphan-apis .ivu-tag {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const baseModel = require("../../middleware/baseModel");
|
||||
const { build_search_where, normalize_for_write } = require("../utils/query_helpers");
|
||||
const audit = require("../utils/biz_audit");
|
||||
const proxy_api_catalog = require("../service/biz_proxy_api_catalog");
|
||||
|
||||
module.exports = {
|
||||
"POST /biz_plan/page": async (ctx) => {
|
||||
@@ -105,4 +106,8 @@ module.exports = {
|
||||
});
|
||||
ctx.success({ rows });
|
||||
},
|
||||
/** 转发接口目录(与 swagger 一致),用于配置套餐 allowed_apis */
|
||||
"POST /biz_plan/proxy_api_catalog": async (ctx) => {
|
||||
ctx.success(proxy_api_catalog.buildCatalog());
|
||||
},
|
||||
};
|
||||
|
||||
54
api/service/biz_proxy_api_catalog.js
Normal file
54
api/service/biz_proxy_api_catalog.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const swagger = require("../../_docs/swagger.json");
|
||||
|
||||
const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "options"]);
|
||||
|
||||
/**
|
||||
* 与 proxy_api.buildProxyRoutes 使用同一套 swagger.paths,供套餐「接口白名单」勾选
|
||||
* @returns {{ items: object[], groups: Record<string, object[]>, tags: string[] }}
|
||||
*/
|
||||
function buildCatalog() {
|
||||
const byPath = new Map();
|
||||
const paths = swagger.paths || {};
|
||||
|
||||
for (const [routePath, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
if (!HTTP_METHODS.has(method.toLowerCase())) continue;
|
||||
if (!spec || typeof spec !== "object") continue;
|
||||
const tag = (spec.tags && spec.tags[0]) || "其他";
|
||||
const summary = spec.summary || spec.operationId || "";
|
||||
if (!byPath.has(routePath)) {
|
||||
byPath.set(routePath, {
|
||||
path: routePath,
|
||||
methods: new Set(),
|
||||
summary: summary || "",
|
||||
tag,
|
||||
});
|
||||
}
|
||||
const row = byPath.get(routePath);
|
||||
row.methods.add(method.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(byPath.values())
|
||||
.map((x) => ({
|
||||
path: x.path,
|
||||
methods: Array.from(x.methods).sort(),
|
||||
summary: x.summary || "",
|
||||
tag: x.tag,
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
/** @type {Record<string, object[]>} */
|
||||
const groups = {};
|
||||
for (const it of items) {
|
||||
if (!groups[it.tag]) groups[it.tag] = [];
|
||||
groups[it.tag].push(it);
|
||||
}
|
||||
|
||||
const tags = Object.keys(groups).sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||||
|
||||
return { items, groups, tags };
|
||||
}
|
||||
|
||||
module.exports = { buildCatalog };
|
||||
@@ -135,12 +135,21 @@ async function incrementApiCallCount(userId, planId, statMonth) {
|
||||
*/
|
||||
function checkApiPathAllowed(plan, apiPath) {
|
||||
const allowed = plan.allowed_apis;
|
||||
// null / undefined:不限制接口
|
||||
if (allowed == null) return { ok: true };
|
||||
let list = allowed;
|
||||
if (typeof list === "string") {
|
||||
try { list = JSON.parse(list); } catch { return { ok: true }; }
|
||||
try {
|
||||
list = JSON.parse(list);
|
||||
} catch {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return { ok: true };
|
||||
// 空数组:明确配置为「不允许任何转发接口」
|
||||
if (list.length === 0) {
|
||||
return { ok: false, error_code: "API_NOT_ALLOWED", message: "当前套餐未开放任何接口" };
|
||||
}
|
||||
if (!Array.isArray(list) || list.length === 0) return { ok: true };
|
||||
if (list.includes(apiPath)) return { ok: true };
|
||||
return { ok: false, error_code: "API_NOT_ALLOWED", message: `当前套餐不支持该接口: ${apiPath}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user