From 2f0445949203bcc99614156e9cd6348a7b0e7e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Fri, 27 Mar 2026 13:14:10 +0800 Subject: [PATCH] 1 --- _docs/sql/biz_api_call_log.sql | 14 +++ api/controller_admin/biz_api_stats.js | 53 +++++++++++ api/controller_front/proxy_api.js | 85 +++++++++++++++++ api/model/biz_api_call_log.js | 68 ++++++++++++++ api/model/biz_plan.js | 11 +++ api/service/biz_api_stats_service.js | 126 ++++++++++++++++++++++++++ api/service/biz_proxy_service.js | 83 +++++++++++++++++ config/config.development.js | 3 + config/config.js | 20 +++- config/config.production.js | 3 + 10 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 _docs/sql/biz_api_call_log.sql create mode 100644 api/controller_admin/biz_api_stats.js create mode 100644 api/controller_front/proxy_api.js create mode 100644 api/model/biz_api_call_log.js create mode 100644 api/service/biz_api_stats_service.js create mode 100644 api/service/biz_proxy_service.js diff --git a/_docs/sql/biz_api_call_log.sql b/_docs/sql/biz_api_call_log.sql new file mode 100644 index 0000000..83bb808 --- /dev/null +++ b/_docs/sql/biz_api_call_log.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS `biz_api_call_log` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '业务用户ID', + `token_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的Token ID', + `api_path` VARCHAR(200) NOT NULL COMMENT '接口路径', + `http_method` VARCHAR(10) NOT NULL DEFAULT 'POST', + `status_code` INT NOT NULL DEFAULT 0 COMMENT '上游返回的HTTP状态码', + `response_time` INT NOT NULL DEFAULT 0 COMMENT '上游响应耗时ms', + `call_date` DATE NOT NULL COMMENT '调用日期', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_user_date` (`user_id`, `call_date`), + INDEX `idx_api_date` (`api_path`, `call_date`), + INDEX `idx_user_api` (`user_id`, `api_path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API调用日志'; diff --git a/api/controller_admin/biz_api_stats.js b/api/controller_admin/biz_api_stats.js new file mode 100644 index 0000000..4c3fba5 --- /dev/null +++ b/api/controller_admin/biz_api_stats.js @@ -0,0 +1,53 @@ +const stats = require("../service/biz_api_stats_service"); +const crud = require("../service/biz_admin_crud"); + +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 {}; +} + +module.exports = { + /** 按用户查询调用统计 */ + "POST /biz_api_stats/by_user": async (ctx) => { + const body = getRequestBody(ctx); + const { user_id, start_date, end_date } = body; + if (!user_id) { + ctx.fail("缺少 user_id"); + return; + } + const data = await stats.getStatsByUser(user_id, start_date, end_date); + ctx.success(data); + }, + + /** 按接口路径查询调用统计 */ + "POST /biz_api_stats/by_api": async (ctx) => { + const body = getRequestBody(ctx); + const { api_path, start_date, end_date } = body; + if (!api_path) { + ctx.fail("缺少 api_path"); + return; + } + const data = await stats.getStatsByApi(api_path, start_date, end_date); + ctx.success(data); + }, + + /** 综合统计面板 */ + "POST /biz_api_stats/summary": async (ctx) => { + const body = getRequestBody(ctx); + const { start_date, end_date, top_limit } = body; + const data = await stats.getSummary(start_date, end_date, top_limit || 10); + ctx.success(data); + }, + + /** 调用日志分页列表(复用通用 CRUD) */ + "POST /biz_api_call_log/page": async (ctx) => { + const body = getRequestBody(ctx); + const data = await crud.page("biz_api_call_log", body); + ctx.success({ rows: data.rows, count: data.count }); + }, +}; diff --git a/api/controller_front/proxy_api.js b/api/controller_front/proxy_api.js new file mode 100644 index 0000000..fa514eb --- /dev/null +++ b/api/controller_front/proxy_api.js @@ -0,0 +1,85 @@ +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_api_call_log.js b/api/model/biz_api_call_log.js new file mode 100644 index 0000000..b7e2daa --- /dev/null +++ b/api/model/biz_api_call_log.js @@ -0,0 +1,68 @@ +const Sequelize = require("sequelize"); + +module.exports = (db) => { + const biz_api_call_log = db.define( + "biz_api_call_log", + { + id: { + type: Sequelize.BIGINT.UNSIGNED, + primaryKey: true, + autoIncrement: true, + }, + user_id: { + type: Sequelize.BIGINT.UNSIGNED, + allowNull: false, + comment: "业务用户ID", + }, + token_id: { + type: Sequelize.BIGINT.UNSIGNED, + allowNull: false, + comment: "使用的Token ID", + }, + api_path: { + type: Sequelize.STRING(200), + allowNull: false, + comment: "接口路径,如 /user/GetProfile", + }, + http_method: { + type: Sequelize.STRING(10), + allowNull: false, + defaultValue: "POST", + }, + status_code: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + comment: "上游返回的HTTP状态码", + }, + response_time: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + comment: "上游响应耗时ms", + }, + call_date: { + type: Sequelize.DATEONLY, + allowNull: false, + comment: "调用日期,方便按天统计", + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }, + { + tableName: "biz_api_call_log", + timestamps: false, + underscored: true, + comment: "API调用日志", + indexes: [ + { fields: ["user_id", "call_date"], name: "idx_user_date" }, + { fields: ["api_path", "call_date"], name: "idx_api_date" }, + { fields: ["user_id", "api_path"], name: "idx_user_api" }, + ], + } + ); + return biz_api_call_log; +}; diff --git a/api/model/biz_plan.js b/api/model/biz_plan.js index 154fc07..22ed382 100644 --- a/api/model/biz_plan.js +++ b/api/model/biz_plan.js @@ -40,6 +40,17 @@ module.exports = (db) => { allowNull: true, comment: "JSON 功能点白名单", }, + allowed_apis: { + type: Sequelize.JSON, + allowNull: true, + comment: "可访问的接口路径列表,如 [\"/user/GetProfile\",\"/message/SendText\"],null 表示不限制", + }, + api_call_quota: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + comment: "每月 API 总调用次数上限,0=不限制", + }, status: { type: Sequelize.ENUM("active", "inactive"), allowNull: false, diff --git a/api/service/biz_api_stats_service.js b/api/service/biz_api_stats_service.js new file mode 100644 index 0000000..1430655 --- /dev/null +++ b/api/service/biz_api_stats_service.js @@ -0,0 +1,126 @@ +const baseModel = require("../../middleware/baseModel"); +const { op } = baseModel; + +/** + * 构建日期范围 where 条件 + */ +function buildDateWhere(start_date, end_date) { + const where = {}; + if (start_date && end_date) { + where.call_date = { [op.between]: [start_date, end_date] }; + } else if (start_date) { + where.call_date = { [op.gte]: start_date }; + } else if (end_date) { + where.call_date = { [op.lte]: end_date }; + } + return where; +} + +/** + * 按用户统计调用量(按接口路径分组) + * @param {number} user_id + * @param {string} [start_date] - YYYY-MM-DD + * @param {string} [end_date] - YYYY-MM-DD + */ +async function getStatsByUser(user_id, start_date, end_date) { + const where = { user_id, ...buildDateWhere(start_date, end_date) }; + const rows = await baseModel.biz_api_call_log.findAll({ + attributes: [ + "api_path", + [baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"], + [baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"], + ], + where, + group: ["api_path"], + order: [[baseModel.Sequelize.literal("call_count"), "DESC"]], + raw: true, + }); + + const total = rows.reduce((s, r) => s + Number(r.call_count), 0); + return { total, rows }; +} + +/** + * 按接口路径统计调用量(按用户分组) + * @param {string} api_path + * @param {string} [start_date] + * @param {string} [end_date] + */ +async function getStatsByApi(api_path, start_date, end_date) { + const where = { api_path, ...buildDateWhere(start_date, end_date) }; + const rows = await baseModel.biz_api_call_log.findAll({ + attributes: [ + "user_id", + [baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"], + [baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"], + ], + where, + group: ["user_id"], + order: [[baseModel.Sequelize.literal("call_count"), "DESC"]], + raw: true, + }); + + const total = rows.reduce((s, r) => s + Number(r.call_count), 0); + return { total, rows }; +} + +/** + * 综合统计面板:总调用量、按天趋势、Top 接口、Top 用户 + * @param {string} [start_date] + * @param {string} [end_date] + * @param {number} [top_limit=10] + */ +async function getSummary(start_date, end_date, top_limit = 10) { + const dateWhere = buildDateWhere(start_date, end_date); + const Seq = baseModel.Sequelize; + + // 总调用量 + const totalResult = await baseModel.biz_api_call_log.count({ where: dateWhere }); + + // 按天趋势 + const daily_trend = await baseModel.biz_api_call_log.findAll({ + attributes: [ + "call_date", + [Seq.fn("COUNT", Seq.col("id")), "call_count"], + ], + where: dateWhere, + group: ["call_date"], + order: [["call_date", "ASC"]], + raw: true, + }); + + // Top 接口 + const top_apis = await baseModel.biz_api_call_log.findAll({ + attributes: [ + "api_path", + [Seq.fn("COUNT", Seq.col("id")), "call_count"], + ], + where: dateWhere, + group: ["api_path"], + order: [[Seq.literal("call_count"), "DESC"]], + limit: top_limit, + raw: true, + }); + + // Top 用户 + const top_users = await baseModel.biz_api_call_log.findAll({ + attributes: [ + "user_id", + [Seq.fn("COUNT", Seq.col("id")), "call_count"], + ], + where: dateWhere, + group: ["user_id"], + order: [[Seq.literal("call_count"), "DESC"]], + limit: top_limit, + raw: true, + }); + + return { + total_calls: totalResult, + daily_trend, + top_apis, + top_users, + }; +} + +module.exports = { getStatsByUser, getStatsByApi, getSummary }; diff --git a/api/service/biz_proxy_service.js b/api/service/biz_proxy_service.js new file mode 100644 index 0000000..1042876 --- /dev/null +++ b/api/service/biz_proxy_service.js @@ -0,0 +1,83 @@ +const axios = require("axios"); +const baseModel = require("../../middleware/baseModel"); +const config = require("../../config/config"); +const logs = require("../../tool/logs_proxy"); + +const upstreamBaseUrl = config.upstream_api_url || "http://127.0.0.1:8888"; + +/** + * 转发请求到上游并记录调用日志 + * @param {object} params + * @param {string} params.api_path - 接口路径,如 /user/GetProfile + * @param {string} params.method - HTTP 方法 + * @param {object} params.query - query 参数(透传) + * @param {object} params.body - body 参数(透传) + * @param {object} params.headers - 需要透传的请求头 + * @param {object} params.auth_ctx - 鉴权上下文(verifyRequest 返回的 context) + * @returns {object} { status, data, headers } + */ +async function forwardRequest({ api_path, method, query, body, headers, auth_ctx }) { + const url = `${upstreamBaseUrl}${api_path}`; + const start = Date.now(); + let status_code = 0; + let resp_data = null; + let resp_headers = {}; + + try { + const forwardHeaders = {}; + if (headers["content-type"]) forwardHeaders["content-type"] = headers["content-type"]; + if (headers["user-agent"]) forwardHeaders["user-agent"] = headers["user-agent"]; + + const resp = await axios({ + method: method.toLowerCase(), + url, + params: query, + data: body, + headers: forwardHeaders, + timeout: 30000, + validateStatus: () => true, + }); + + status_code = resp.status; + resp_data = resp.data; + resp_headers = resp.headers; + } catch (err) { + status_code = 502; + resp_data = { ok: false, error_code: "UPSTREAM_ERROR", message: err.message }; + logs.error(`[proxy] 转发失败 ${api_path}`, err.message); + } + + const response_time = Date.now() - start; + + // 异步写调用日志,不阻塞响应 + writeCallLog({ + user_id: auth_ctx.user_id, + token_id: auth_ctx.token_id, + api_path, + http_method: method.toUpperCase(), + status_code, + response_time, + }).catch((e) => logs.error("[proxy] 写调用日志失败", e.message)); + + return { status: status_code, data: resp_data, headers: resp_headers }; +} + +/** + * 写入 API 调用日志 + */ +async function writeCallLog({ user_id, token_id, api_path, http_method, status_code, response_time }) { + const now = new Date(); + const call_date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + await baseModel.biz_api_call_log.create({ + user_id, + token_id, + api_path, + http_method, + status_code, + response_time, + call_date, + created_at: now, + }); +} + +module.exports = { forwardRequest }; diff --git a/config/config.development.js b/config/config.development.js index 4b5c913..9d98adf 100644 --- a/config/config.development.js +++ b/config/config.development.js @@ -36,5 +36,8 @@ module.exports = { "apiKey": "", "baseUrl": "https://dashscope.aliyuncs.com/api/v1" }, + + // 官方 API 上游地址(转发层目标) + "upstream_api_url": "http://127.0.0.1:8888", } diff --git a/config/config.js b/config/config.js index 45d22fb..ff9ad6a 100644 --- a/config/config.js +++ b/config/config.js @@ -28,7 +28,25 @@ const baseConfig = { "/sys_file/", "/api/docs", "api/swagger.json", - "/api/auth/verify" + "/api/auth/verify", + // 转发层路由白名单(框架不鉴权,由控制器内部做 Token 鉴权) + "/api/admin/", + "/api/applet/", + "/api/equipment/", + "/api/favor/", + "/api/finder/", + "/api/friend/", + "/api/group/", + "/api/label/", + "/api/login/", + "/api/message/", + "/api/other/", + "/api/pay/", + "/api/qy/", + "/api/shop/", + "/api/sns/", + "/api/user/", + "/api/ws/" ] }; diff --git a/config/config.production.js b/config/config.production.js index dec6d38..598633f 100644 --- a/config/config.production.js +++ b/config/config.production.js @@ -39,5 +39,8 @@ module.exports = { "apiKey": "", "baseUrl": "https://dashscope.aliyuncs.com/api/v1" }, + + // 官方 API 上游地址(转发层目标) + "upstream_api_url": "http://127.0.0.1:8888", }