This commit is contained in:
张成
2026-03-27 13:14:10 +08:00
parent 42907d0f21
commit 2f04459492
10 changed files with 465 additions and 1 deletions

View File

@@ -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调用日志';

View File

@@ -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 });
},
};

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -40,6 +40,17 @@ module.exports = (db) => {
allowNull: true, allowNull: true,
comment: "JSON 功能点白名单", 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: { status: {
type: Sequelize.ENUM("active", "inactive"), type: Sequelize.ENUM("active", "inactive"),
allowNull: false, allowNull: false,

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -36,5 +36,8 @@ module.exports = {
"apiKey": "", "apiKey": "",
"baseUrl": "https://dashscope.aliyuncs.com/api/v1" "baseUrl": "https://dashscope.aliyuncs.com/api/v1"
}, },
// 官方 API 上游地址(转发层目标)
"upstream_api_url": "http://127.0.0.1:8888",
} }

View File

@@ -28,7 +28,25 @@ const baseConfig = {
"/sys_file/", "/sys_file/",
"/api/docs", "/api/docs",
"api/swagger.json", "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/"
] ]
}; };

View File

@@ -39,5 +39,8 @@ module.exports = {
"apiKey": "", "apiKey": "",
"baseUrl": "https://dashscope.aliyuncs.com/api/v1" "baseUrl": "https://dashscope.aliyuncs.com/api/v1"
}, },
// 官方 API 上游地址(转发层目标)
"upstream_api_url": "http://127.0.0.1:8888",
} }