1
This commit is contained in:
@@ -274,6 +274,7 @@ const definitions = {
|
|||||||
|
|
||||||
const paths = {};
|
const paths = {};
|
||||||
paths["/admin_api/biz_api_call_log/page"] = post("API 调用明细分页", "BizAdminPageRequest");
|
paths["/admin_api/biz_api_call_log/page"] = post("API 调用明细分页", "BizAdminPageRequest");
|
||||||
|
paths["/admin_api/biz_api_call_log/export"] = post("API 调用明细导出", "BizAdminPageRequest");
|
||||||
paths["/admin_api/biz_api_stats/by_user"] = post("按用户统计接口调用", "BizApiStatsByUserRequest");
|
paths["/admin_api/biz_api_stats/by_user"] = post("按用户统计接口调用", "BizApiStatsByUserRequest");
|
||||||
paths["/admin_api/biz_api_stats/by_api"] = post("按接口路径统计调用", "BizApiStatsByApiRequest");
|
paths["/admin_api/biz_api_stats/by_api"] = post("按接口路径统计调用", "BizApiStatsByApiRequest");
|
||||||
paths["/admin_api/biz_api_stats/summary"] = post("调用量汇总与趋势", "BizApiStatsSummaryRequest");
|
paths["/admin_api/biz_api_stats/summary"] = post("调用量汇总与趋势", "BizApiStatsSummaryRequest");
|
||||||
|
|||||||
3
_docs/sql/biz_api_call_log_response_body.sql
Normal file
3
_docs/sql/biz_api_call_log_response_body.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 调用日志:存储上游响应摘要(与 api/model/biz_api_call_log.js 中 response_body 一致)
|
||||||
|
ALTER TABLE `biz_api_call_log`
|
||||||
|
ADD COLUMN `response_body` TEXT NULL COMMENT '上游响应体 JSON 文本(截断存储)' AFTER `response_time`;
|
||||||
11
admin/src/api/subscription/api_call_log_server.js
Normal file
11
admin/src/api/subscription/api_call_log_server.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class ApiCallLogServer {
|
||||||
|
async page(row) {
|
||||||
|
return window.framework.http.post("/biz_api_call_log/page", row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportRows(row) {
|
||||||
|
return window.framework.http.post("/biz_api_call_log/export", row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ApiCallLogServer();
|
||||||
@@ -8,6 +8,7 @@ import SubscriptionTokens from '../views/subscription/tokens.vue'
|
|||||||
import SubscriptionPayment from '../views/subscription/payment.vue'
|
import SubscriptionPayment from '../views/subscription/payment.vue'
|
||||||
import SubscriptionUsage from '../views/subscription/usage.vue'
|
import SubscriptionUsage from '../views/subscription/usage.vue'
|
||||||
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
|
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
|
||||||
|
import SubscriptionApiCallLog from '../views/subscription/api_call_log.vue'
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
// 与 sys_menu.component 一致:库中常见为 home/index 或 home/index.vue
|
// 与 sys_menu.component 一致:库中常见为 home/index 或 home/index.vue
|
||||||
@@ -22,6 +23,7 @@ const componentMap = {
|
|||||||
'subscription/payment': SubscriptionPayment,
|
'subscription/payment': SubscriptionPayment,
|
||||||
'subscription/usage': SubscriptionUsage,
|
'subscription/usage': SubscriptionUsage,
|
||||||
'subscription/audit': SubscriptionAuditLog,
|
'subscription/audit': SubscriptionAuditLog,
|
||||||
|
'subscription/api_call_log': SubscriptionApiCallLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default componentMap;
|
export default componentMap;
|
||||||
|
|||||||
202
admin/src/views/subscription/api_call_log.vue
Normal file
202
admin/src/views/subscription/api_call_log.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<div class="content-view">
|
||||||
|
<div class="table-head-tool">
|
||||||
|
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
|
||||||
|
<FormItem label="筛选">
|
||||||
|
<Select v-model="param.seachOption.key" style="width: 140px" @on-change="onSearchKeyChange">
|
||||||
|
<Option value="user_id">user_id</Option>
|
||||||
|
<Option value="token_id">token_id</Option>
|
||||||
|
<Option value="api_path">api_path</Option>
|
||||||
|
<Option value="http_method">http_method</Option>
|
||||||
|
<Option value="status_code">status_code</Option>
|
||||||
|
<Option value="response_body">response_body</Option>
|
||||||
|
</Select>
|
||||||
|
<Input v-model="param.seachOption.value" class="ml10" style="width: 260px" placeholder="支持数值或路径片段" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<Button type="primary" @click="load(1)">查询</Button>
|
||||||
|
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
|
||||||
|
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
|
||||||
|
<DropdownMenu slot="list">
|
||||||
|
<DropdownItem name="export">导出 CSV</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-body">
|
||||||
|
<Table :columns="columns" :data="rows" border stripe />
|
||||||
|
<Modal v-model="detailVisible" title="响应结果(日志内已截断)" width="760" footer-hide>
|
||||||
|
<Input v-model="detailBody" type="textarea" :rows="20" readonly class="resp-detail-ta" />
|
||||||
|
</Modal>
|
||||||
|
<div class="table-page-bar">
|
||||||
|
<Page
|
||||||
|
:total="total"
|
||||||
|
:current="param.pageOption.page"
|
||||||
|
:page-size="param.pageOption.pageSize"
|
||||||
|
show-total
|
||||||
|
@on-change="onPage"
|
||||||
|
@on-page-size-change="onSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import apiCallLogServer from '@/api/subscription/api_call_log_server.js'
|
||||||
|
import { downloadCsvFromRows } from '@/utils/csvExport.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SubscriptionApiCallLog',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
detailVisible: false,
|
||||||
|
detailBody: '',
|
||||||
|
rows: [],
|
||||||
|
total: 0,
|
||||||
|
param: {
|
||||||
|
seachOption: { key: 'user_id', value: '' },
|
||||||
|
pageOption: { page: 1, pageSize: 20, total: 0 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
columns() {
|
||||||
|
return [
|
||||||
|
{ title: 'ID', key: 'id', width: 80 },
|
||||||
|
{ title: '用户', key: 'user_id', width: 96 },
|
||||||
|
{ title: 'Token', key: 'token_id', width: 96 },
|
||||||
|
{ title: '接口路径', key: 'api_path', minWidth: 200, ellipsis: true, tooltip: true },
|
||||||
|
{ title: '方法', key: 'http_method', width: 88 },
|
||||||
|
{ title: 'HTTP状态', key: 'status_code', width: 96 },
|
||||||
|
{
|
||||||
|
title: '响应结果',
|
||||||
|
key: 'response_body',
|
||||||
|
minWidth: 220,
|
||||||
|
render: (h, p) => {
|
||||||
|
const s = p.row.response_body == null ? '' : String(p.row.response_body)
|
||||||
|
if (!s) return h('span', { class: 'muted' }, '—')
|
||||||
|
const short = s.length > 72 ? `${s.slice(0, 72)}…` : s
|
||||||
|
return h('div', { class: 'resp-cell' }, [
|
||||||
|
h('span', { class: 'resp-preview', attrs: { title: s } }, short),
|
||||||
|
h(
|
||||||
|
'Button',
|
||||||
|
{
|
||||||
|
props: { type: 'primary', size: 'small' },
|
||||||
|
class: 'ml8',
|
||||||
|
on: {
|
||||||
|
click: (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.open_response_detail(s)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'查看'
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '耗时ms', key: 'response_time', width: 96 },
|
||||||
|
{ title: '统计日', key: 'call_date', width: 120 },
|
||||||
|
{ title: '创建时间', key: 'created_at', minWidth: 168 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSearchKeyChange() {
|
||||||
|
this.param.seachOption.value = ''
|
||||||
|
},
|
||||||
|
open_response_detail(text) {
|
||||||
|
this.detailBody = text
|
||||||
|
this.detailVisible = true
|
||||||
|
},
|
||||||
|
async load(page) {
|
||||||
|
if (page) this.param.pageOption.page = page
|
||||||
|
const res = await apiCallLogServer.page({ param: this.param })
|
||||||
|
if (res && res.code === 0) {
|
||||||
|
this.rows = res.data.rows || []
|
||||||
|
this.total = res.data.count || 0
|
||||||
|
} else {
|
||||||
|
this.$Message.error((res && res.message) || '加载失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPage(p) {
|
||||||
|
this.param.pageOption.page = p
|
||||||
|
this.load()
|
||||||
|
},
|
||||||
|
onSize(s) {
|
||||||
|
this.param.pageOption.pageSize = s
|
||||||
|
this.load(1)
|
||||||
|
},
|
||||||
|
on_toolbar_more(name) {
|
||||||
|
if (name === 'export') this.doExport()
|
||||||
|
},
|
||||||
|
async doExport() {
|
||||||
|
const res = await apiCallLogServer.exportRows({ param: this.param })
|
||||||
|
if (res && res.code === 0 && res.data && res.data.rows) {
|
||||||
|
downloadCsvFromRows(res.data.rows, 'biz_api_call_log.csv')
|
||||||
|
this.$Message.success('已导出')
|
||||||
|
} else {
|
||||||
|
this.$Message.error((res && res.message) || '导出失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.content-view {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 36px 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head-tool {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml10 {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-page-bar {
|
||||||
|
margin-top: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #c5c8ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resp-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resp-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resp-detail-ta :deep(textarea) {
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -123,4 +123,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
ctx.success({ rows, count });
|
ctx.success({ rows, count });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"POST /biz_api_call_log/export": async (ctx) => {
|
||||||
|
const body = ctx.getBody();
|
||||||
|
const param = body.param || body;
|
||||||
|
const biz_api_call_log = baseModel.biz_api_call_log;
|
||||||
|
const where = build_search_where(biz_api_call_log, param.seachOption || {});
|
||||||
|
const rows = await biz_api_call_log.findAll({
|
||||||
|
where,
|
||||||
|
limit: 10000,
|
||||||
|
order: [["id", "DESC"]],
|
||||||
|
});
|
||||||
|
ctx.success({ rows });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ module.exports = (db) => {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
response_body: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
call_date: {
|
call_date: {
|
||||||
type: Sequelize.DATEONLY,
|
type: Sequelize.DATEONLY,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
@@ -50,6 +54,6 @@ module.exports = (db) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
//biz_api_call_log.sync({ force: true });
|
//biz_api_call_log.sync({ force: true });
|
||||||
return biz_api_call_log;
|
return biz_api_call_log;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ const logs = require("../../tool/logs_proxy");
|
|||||||
|
|
||||||
const upstreamBaseUrl = config.upstream_api_url || "http://127.0.0.1:8888";
|
const upstreamBaseUrl = config.upstream_api_url || "http://127.0.0.1:8888";
|
||||||
|
|
||||||
|
/** 写入日志用:序列化响应并截断,避免 TEXT 过大 */
|
||||||
|
const RESPONSE_BODY_MAX_LEN = 16000;
|
||||||
|
|
||||||
|
function serialize_response_for_log(data) {
|
||||||
|
if (data === undefined || data === null) return "";
|
||||||
|
let s;
|
||||||
|
if (typeof data === "string") s = data;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
s = JSON.stringify(data);
|
||||||
|
} catch (e) {
|
||||||
|
s = String(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.length > RESPONSE_BODY_MAX_LEN) {
|
||||||
|
return `${s.slice(0, RESPONSE_BODY_MAX_LEN)}…[truncated]`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转发请求到上游并记录调用日志
|
* 转发请求到上游并记录调用日志
|
||||||
* @param {object} params
|
* @param {object} params
|
||||||
@@ -57,6 +77,7 @@ async function forwardRequest({ api_path, method, query, body, headers, auth_ctx
|
|||||||
http_method: method.toUpperCase(),
|
http_method: method.toUpperCase(),
|
||||||
status_code,
|
status_code,
|
||||||
response_time,
|
response_time,
|
||||||
|
response_body: serialize_response_for_log(resp_data),
|
||||||
}).catch((e) => logs.error("[proxy] 写调用日志失败", e.message));
|
}).catch((e) => logs.error("[proxy] 写调用日志失败", e.message));
|
||||||
|
|
||||||
return { status: status_code, data: resp_data, headers: resp_headers };
|
return { status: status_code, data: resp_data, headers: resp_headers };
|
||||||
@@ -65,7 +86,15 @@ async function forwardRequest({ api_path, method, query, body, headers, auth_ctx
|
|||||||
/**
|
/**
|
||||||
* 写入 API 调用日志
|
* 写入 API 调用日志
|
||||||
*/
|
*/
|
||||||
async function writeCallLog({ user_id, token_id, api_path, http_method, status_code, response_time }) {
|
async function writeCallLog({
|
||||||
|
user_id,
|
||||||
|
token_id,
|
||||||
|
api_path,
|
||||||
|
http_method,
|
||||||
|
status_code,
|
||||||
|
response_time,
|
||||||
|
response_body,
|
||||||
|
}) {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const call_date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
const call_date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
@@ -76,6 +105,7 @@ async function writeCallLog({ user_id, token_id, api_path, http_method, status_c
|
|||||||
http_method,
|
http_method,
|
||||||
status_code,
|
status_code,
|
||||||
response_time,
|
response_time,
|
||||||
|
response_body: response_body || null,
|
||||||
call_date,
|
call_date,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user