1
This commit is contained in:
8
_docs/sql/alter_plan_api_permission.sql
Normal file
8
_docs/sql/alter_plan_api_permission.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- biz_plans 新增:可访问接口列表 + 每月API调用次数上限
|
||||
ALTER TABLE `biz_plans`
|
||||
ADD COLUMN `allowed_apis` JSON DEFAULT NULL COMMENT '可访问的接口路径列表,null=不限制' AFTER `enabled_features`,
|
||||
ADD COLUMN `api_call_quota` INT NOT NULL DEFAULT 0 COMMENT '每月API总调用次数上限,0=不限制' AFTER `allowed_apis`;
|
||||
|
||||
-- biz_usage_monthly 新增:当月API调用总次数
|
||||
ALTER TABLE `biz_usage_monthly`
|
||||
ADD COLUMN `api_call_count` INT NOT NULL DEFAULT 0 COMMENT '当月API转发总调用次数' AFTER `active_user_count`;
|
||||
89
api/controller_custom/proxy_api.js
Normal file
89
api/controller_custom/proxy_api.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const swagger = require("../../_docs/swagger.json");
|
||||
const auth = require("../service/biz_auth_verify");
|
||||
const proxy = require("../service/biz_proxy_service");
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取 Token
|
||||
* 支持 Authorization: Bearer xxx 和 query ?token=xxx
|
||||
*/
|
||||
function extractToken(ctx) {
|
||||
const authHeader = ctx.get("Authorization") || "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.slice(7).trim();
|
||||
}
|
||||
return ctx.query.token || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 swagger tags 第一项作为 feature 名(用于套餐功能点校验)
|
||||
*/
|
||||
function pickFeature(spec) {
|
||||
if (spec.tags && spec.tags.length > 0) {
|
||||
return spec.tags[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建转发路由表(供 framework.addRoutes 注册)
|
||||
*/
|
||||
function buildProxyRoutes() {
|
||||
const routes = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(swagger.paths)) {
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
const routeKey = `${method.toUpperCase()} ${path}`;
|
||||
|
||||
routes[routeKey] = async (ctx) => {
|
||||
// 1. 提取 Token
|
||||
const token = extractToken(ctx);
|
||||
if (!token) {
|
||||
ctx.status = 401;
|
||||
ctx.body = { ok: false, error_code: "TOKEN_MISSING", message: "缺少 Token" };
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 鉴权:Token + 用户 + 订阅 + 套餐功能点 + 接口权限 + 调用量
|
||||
const feature = pickFeature(spec);
|
||||
const authResult = await auth.verifyRequest({ token, feature, api_path: path });
|
||||
if (!authResult.ok) {
|
||||
ctx.status = 403;
|
||||
ctx.body = authResult;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 组装 query(去掉 token 参数,避免泄露)
|
||||
const query = { ...ctx.query };
|
||||
delete query.token;
|
||||
|
||||
// 4. 转发到上游
|
||||
const result = await proxy.forwardRequest({
|
||||
api_path: path,
|
||||
method: method.toUpperCase(),
|
||||
query,
|
||||
body: getRequestBody(ctx),
|
||||
headers: ctx.headers || {},
|
||||
auth_ctx: authResult.context,
|
||||
});
|
||||
|
||||
// 5. 原样返回上游响应
|
||||
ctx.status = result.status;
|
||||
ctx.body = result.data;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
module.exports = { buildProxyRoutes };
|
||||
@@ -1,85 +0,0 @@
|
||||
const swagger = require("../../_docs/swagger.json");
|
||||
const auth = require("../service/biz_auth_verify");
|
||||
const proxy = require("../service/biz_proxy_service");
|
||||
|
||||
function getRequestBody(ctx) {
|
||||
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
|
||||
return ctx.request.body;
|
||||
}
|
||||
if (typeof ctx.getBody === "function") {
|
||||
return ctx.getBody() || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中提取 Token
|
||||
* 支持 Authorization: Bearer xxx 和 query ?token=xxx
|
||||
*/
|
||||
function extractToken(ctx) {
|
||||
const authHeader = ctx.get("Authorization") || "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.slice(7).trim();
|
||||
}
|
||||
return ctx.query.token || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 swagger tags 第一项作为 feature 名(用于套餐功能点校验)
|
||||
*/
|
||||
function pickFeature(spec) {
|
||||
if (spec.tags && spec.tags.length > 0) {
|
||||
return spec.tags[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 swagger.json 动态生成全部转发路由
|
||||
*/
|
||||
const routes = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(swagger.paths)) {
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
const routeKey = `${method.toUpperCase()} ${path}`;
|
||||
|
||||
routes[routeKey] = async (ctx) => {
|
||||
// 1. 提取 Token
|
||||
const token = extractToken(ctx);
|
||||
if (!token) {
|
||||
ctx.status = 401;
|
||||
ctx.body = { ok: false, error_code: "TOKEN_MISSING", message: "缺少 Token" };
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 鉴权:Token + 用户 + 订阅 + 套餐功能点
|
||||
const feature = pickFeature(spec);
|
||||
const authResult = await auth.verifyRequest({ token, feature });
|
||||
if (!authResult.ok) {
|
||||
ctx.status = 403;
|
||||
ctx.body = authResult;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 组装 query(去掉 token 参数,避免泄露)
|
||||
const query = { ...ctx.query };
|
||||
delete query.token;
|
||||
|
||||
// 4. 转发到上游
|
||||
const result = await proxy.forwardRequest({
|
||||
api_path: path,
|
||||
method: method.toUpperCase(),
|
||||
query,
|
||||
body: getRequestBody(ctx),
|
||||
headers: ctx.headers || {},
|
||||
auth_ctx: authResult.context,
|
||||
});
|
||||
|
||||
// 5. 原样返回上游响应
|
||||
ctx.status = result.status;
|
||||
ctx.body = result.data;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = routes;
|
||||
@@ -27,6 +27,12 @@ module.exports = (db) => {
|
||||
friend_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
sns_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
active_user_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||
api_call_count: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: "当月 API 转发总调用次数",
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_usage_monthly",
|
||||
|
||||
@@ -29,11 +29,11 @@ function hasPositiveDelta(delta) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外鉴权:Token + 用户 + 有效订阅 + 功能点 + 可选用量上报与额度
|
||||
* body: { token, feature?, usage_delta?: { msg?, mass?, ... } }
|
||||
* 对外鉴权:Token + 用户 + 有效订阅 + 功能点 + 接口权限 + API调用量 + 可选用量上报
|
||||
* body: { token, feature?, api_path?, usage_delta?: { msg?, mass?, ... } }
|
||||
*/
|
||||
async function verifyRequest(body) {
|
||||
const { token, feature } = body || {};
|
||||
const { token, feature, api_path } = body || {};
|
||||
if (!token) {
|
||||
return { ok: false, error_code: "TOKEN_INVALID", message: "缺少 token" };
|
||||
}
|
||||
@@ -71,9 +71,26 @@ async function verifyRequest(body) {
|
||||
return { ok: false, error_code: "FEATURE_NOT_ALLOWED", message: "功能未在套餐内" };
|
||||
}
|
||||
|
||||
// 接口路径级权限校验
|
||||
if (api_path) {
|
||||
const apiCheck = usageSvc.checkApiPathAllowed(plan, api_path);
|
||||
if (!apiCheck.ok) {
|
||||
return { ok: false, error_code: apiCheck.error_code, message: apiCheck.message };
|
||||
}
|
||||
}
|
||||
|
||||
const statMonth = usageSvc.currentStatMonth();
|
||||
let usageRow = await usageSvc.getOrCreateUsage(row.user_id, sub.plan_id, statMonth);
|
||||
|
||||
// API 调用次数配额校验
|
||||
if (api_path) {
|
||||
const callCheck = usageSvc.checkApiCallQuota(plan, usageRow);
|
||||
if (!callCheck.ok) {
|
||||
return { ok: false, error_code: callCheck.error_code, message: callCheck.message };
|
||||
}
|
||||
usageRow = await usageSvc.incrementApiCallCount(row.user_id, sub.plan_id, statMonth);
|
||||
}
|
||||
|
||||
const delta = normalizeUsageDelta(body.usage_delta || body.usage_report);
|
||||
if (hasPositiveDelta(delta)) {
|
||||
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
|
||||
@@ -99,6 +116,7 @@ async function verifyRequest(body) {
|
||||
friend_count: usageSvc.num(usageRow.friend_count),
|
||||
sns_count: usageSvc.num(usageRow.sns_count),
|
||||
active_user_count: usageSvc.num(usageRow.active_user_count),
|
||||
api_call_count: usageSvc.num(usageRow.api_call_count),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ async function getOrCreateUsage(userId, planId, statMonth) {
|
||||
friend_count: 0,
|
||||
sns_count: 0,
|
||||
active_user_count: 0,
|
||||
api_call_count: 0,
|
||||
},
|
||||
});
|
||||
if (num(row.plan_id) !== num(planId)) {
|
||||
@@ -100,11 +101,57 @@ async function ensureUsageRowsForCurrentMonth() {
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 API 调用次数是否超限
|
||||
* @param {object} plan - 套餐记录(含 api_call_quota)
|
||||
* @param {object} usageRow - 当月用量记录(含 api_call_count)
|
||||
* @returns {{ ok: boolean, error_code?: string, message?: string }}
|
||||
*/
|
||||
function checkApiCallQuota(plan, usageRow) {
|
||||
const quota = num(plan.api_call_quota);
|
||||
if (quota <= 0) return { ok: true };
|
||||
const used = num(usageRow.api_call_count) + 1;
|
||||
if (used > quota) {
|
||||
return { ok: false, error_code: "API_CALL_QUOTA_EXCEEDED", message: `当月 API 调用次数已达上限(${quota})` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* API 调用次数 +1
|
||||
*/
|
||||
async function incrementApiCallCount(userId, planId, statMonth) {
|
||||
const row = await getOrCreateUsage(userId, planId, statMonth);
|
||||
await row.update({ api_call_count: num(row.api_call_count) + 1 });
|
||||
return row.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验接口路径是否在套餐允许范围内
|
||||
* @param {object} plan - 套餐记录(含 allowed_apis)
|
||||
* @param {string} apiPath - 当前请求的接口路径,如 /user/GetProfile
|
||||
* @returns {{ ok: boolean, error_code?: string, message?: string }}
|
||||
*/
|
||||
function checkApiPathAllowed(plan, apiPath) {
|
||||
const allowed = plan.allowed_apis;
|
||||
if (allowed == null) return { ok: true };
|
||||
let list = allowed;
|
||||
if (typeof list === "string") {
|
||||
try { list = JSON.parse(list); } catch { return { ok: true }; }
|
||||
}
|
||||
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}` };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
currentStatMonth,
|
||||
getOrCreateUsage,
|
||||
applyDelta,
|
||||
checkQuotaAfterDelta,
|
||||
ensureUsageRowsForCurrentMonth,
|
||||
checkApiCallQuota,
|
||||
incrementApiCallCount,
|
||||
checkApiPathAllowed,
|
||||
num,
|
||||
};
|
||||
|
||||
7
app.js
7
app.js
@@ -18,11 +18,16 @@ async function start() {
|
||||
console.log('⚙️ 正在初始化框架...');
|
||||
|
||||
|
||||
const { buildProxyRoutes } = require('./api/controller_custom/proxy_api');
|
||||
|
||||
const framework = await Framework.init({
|
||||
...config,
|
||||
businessAssociations,
|
||||
beforeInitApi: async (framework) => {
|
||||
|
||||
// 从 swagger.json 动态注册 193 个转发路由到 /api 前缀
|
||||
const proxyRoutes = buildProxyRoutes();
|
||||
framework.addRoutes('/api', proxyRoutes);
|
||||
console.log(`📡 已注册 ${Object.keys(proxyRoutes).length} 个 API 转发路由`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user