Compare commits
2 Commits
42907d0f21
...
1f4b39d576
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f4b39d576 | ||
|
|
2f04459492 |
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`;
|
||||||
14
_docs/sql/biz_api_call_log.sql
Normal file
14
_docs/sql/biz_api_call_log.sql
Normal 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调用日志';
|
||||||
53
api/controller_admin/biz_api_stats.js
Normal file
53
api/controller_admin/biz_api_stats.js
Normal 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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
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 };
|
||||||
68
api/model/biz_api_call_log.js
Normal file
68
api/model/biz_api_call_log.js
Normal 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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ module.exports = (db) => {
|
|||||||
friend_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
friend_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
sns_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 },
|
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",
|
tableName: "biz_usage_monthly",
|
||||||
|
|||||||
126
api/service/biz_api_stats_service.js
Normal file
126
api/service/biz_api_stats_service.js
Normal 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 };
|
||||||
@@ -29,11 +29,11 @@ function hasPositiveDelta(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对外鉴权:Token + 用户 + 有效订阅 + 功能点 + 可选用量上报与额度
|
* 对外鉴权:Token + 用户 + 有效订阅 + 功能点 + 接口权限 + API调用量 + 可选用量上报
|
||||||
* body: { token, feature?, usage_delta?: { msg?, mass?, ... } }
|
* body: { token, feature?, api_path?, usage_delta?: { msg?, mass?, ... } }
|
||||||
*/
|
*/
|
||||||
async function verifyRequest(body) {
|
async function verifyRequest(body) {
|
||||||
const { token, feature } = body || {};
|
const { token, feature, api_path } = body || {};
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return { ok: false, error_code: "TOKEN_INVALID", message: "缺少 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: "功能未在套餐内" };
|
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();
|
const statMonth = usageSvc.currentStatMonth();
|
||||||
let usageRow = await usageSvc.getOrCreateUsage(row.user_id, sub.plan_id, statMonth);
|
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);
|
const delta = normalizeUsageDelta(body.usage_delta || body.usage_report);
|
||||||
if (hasPositiveDelta(delta)) {
|
if (hasPositiveDelta(delta)) {
|
||||||
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
|
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
|
||||||
@@ -99,6 +116,7 @@ async function verifyRequest(body) {
|
|||||||
friend_count: usageSvc.num(usageRow.friend_count),
|
friend_count: usageSvc.num(usageRow.friend_count),
|
||||||
sns_count: usageSvc.num(usageRow.sns_count),
|
sns_count: usageSvc.num(usageRow.sns_count),
|
||||||
active_user_count: usageSvc.num(usageRow.active_user_count),
|
active_user_count: usageSvc.num(usageRow.active_user_count),
|
||||||
|
api_call_count: usageSvc.num(usageRow.api_call_count),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
83
api/service/biz_proxy_service.js
Normal file
83
api/service/biz_proxy_service.js
Normal 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 };
|
||||||
@@ -35,6 +35,7 @@ async function getOrCreateUsage(userId, planId, statMonth) {
|
|||||||
friend_count: 0,
|
friend_count: 0,
|
||||||
sns_count: 0,
|
sns_count: 0,
|
||||||
active_user_count: 0,
|
active_user_count: 0,
|
||||||
|
api_call_count: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (num(row.plan_id) !== num(planId)) {
|
if (num(row.plan_id) !== num(planId)) {
|
||||||
@@ -100,11 +101,57 @@ async function ensureUsageRowsForCurrentMonth() {
|
|||||||
return n;
|
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 = {
|
module.exports = {
|
||||||
currentStatMonth,
|
currentStatMonth,
|
||||||
getOrCreateUsage,
|
getOrCreateUsage,
|
||||||
applyDelta,
|
applyDelta,
|
||||||
checkQuotaAfterDelta,
|
checkQuotaAfterDelta,
|
||||||
ensureUsageRowsForCurrentMonth,
|
ensureUsageRowsForCurrentMonth,
|
||||||
|
checkApiCallQuota,
|
||||||
|
incrementApiCallCount,
|
||||||
|
checkApiPathAllowed,
|
||||||
num,
|
num,
|
||||||
};
|
};
|
||||||
|
|||||||
7
app.js
7
app.js
@@ -18,11 +18,16 @@ async function start() {
|
|||||||
console.log('⚙️ 正在初始化框架...');
|
console.log('⚙️ 正在初始化框架...');
|
||||||
|
|
||||||
|
|
||||||
|
const { buildProxyRoutes } = require('./api/controller_custom/proxy_api');
|
||||||
|
|
||||||
const framework = await Framework.init({
|
const framework = await Framework.init({
|
||||||
...config,
|
...config,
|
||||||
businessAssociations,
|
businessAssociations,
|
||||||
beforeInitApi: async (framework) => {
|
beforeInitApi: async (framework) => {
|
||||||
|
// 从 swagger.json 动态注册 193 个转发路由到 /api 前缀
|
||||||
|
const proxyRoutes = buildProxyRoutes();
|
||||||
|
framework.addRoutes('/api', proxyRoutes);
|
||||||
|
console.log(`📡 已注册 ${Object.keys(proxyRoutes).length} 个 API 转发路由`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/"
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user