From 1f4b39d5761644b5b2d18d8155dd81006bb55078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Fri, 27 Mar 2026 13:30:53 +0800 Subject: [PATCH] 1 --- _docs/sql/alter_plan_api_permission.sql | 8 +++ api/controller_custom/proxy_api.js | 89 +++++++++++++++++++++++++ api/controller_front/proxy_api.js | 85 ----------------------- api/model/biz_usage_monthly.js | 6 ++ api/service/biz_auth_verify.js | 24 ++++++- api/service/biz_usage_service.js | 47 +++++++++++++ app.js | 7 +- 7 files changed, 177 insertions(+), 89 deletions(-) create mode 100644 _docs/sql/alter_plan_api_permission.sql create mode 100644 api/controller_custom/proxy_api.js delete mode 100644 api/controller_front/proxy_api.js diff --git a/_docs/sql/alter_plan_api_permission.sql b/_docs/sql/alter_plan_api_permission.sql new file mode 100644 index 0000000..2aeb535 --- /dev/null +++ b/_docs/sql/alter_plan_api_permission.sql @@ -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`; diff --git a/api/controller_custom/proxy_api.js b/api/controller_custom/proxy_api.js new file mode 100644 index 0000000..c56c188 --- /dev/null +++ b/api/controller_custom/proxy_api.js @@ -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 }; diff --git a/api/controller_front/proxy_api.js b/api/controller_front/proxy_api.js deleted file mode 100644 index fa514eb..0000000 --- a/api/controller_front/proxy_api.js +++ /dev/null @@ -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; diff --git a/api/model/biz_usage_monthly.js b/api/model/biz_usage_monthly.js index ba96d10..362e1e3 100644 --- a/api/model/biz_usage_monthly.js +++ b/api/model/biz_usage_monthly.js @@ -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", diff --git a/api/service/biz_auth_verify.js b/api/service/biz_auth_verify.js index 638d1ba..1df1646 100644 --- a/api/service/biz_auth_verify.js +++ b/api/service/biz_auth_verify.js @@ -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), }, }, }; diff --git a/api/service/biz_usage_service.js b/api/service/biz_usage_service.js index 418c207..2ff9ed5 100644 --- a/api/service/biz_usage_service.js +++ b/api/service/biz_usage_service.js @@ -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, }; diff --git a/app.js b/app.js index 12ba41d..40cbbb2 100644 --- a/app.js +++ b/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 转发路由`); } });