API统计页面

This commit is contained in:
BBIT-Kai
2026-07-27 10:16:24 +08:00
parent 89f6944e65
commit 38efd8ef1a
21 changed files with 3554 additions and 123 deletions
+4
View File
@@ -25,3 +25,7 @@ class SendCubeReportMessageRequest(BaseModel):
class RenameCubeReportConversationRequest(BaseModel): class RenameCubeReportConversationRequest(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
class ExportCubeReportRequest(BaseModel):
columnKeys: list[str] = Field(min_length=1, max_length=16384)
+7
View File
@@ -25,6 +25,7 @@ from db.postgres.cube_report import (
) )
from models.BaseResponse import BaseResponse from models.BaseResponse import BaseResponse
from models.CubeReportRequest import ( from models.CubeReportRequest import (
ExportCubeReportRequest,
RenameCubeReportConversationRequest, RenameCubeReportConversationRequest,
SendCubeReportMessageRequest, SendCubeReportMessageRequest,
) )
@@ -352,11 +353,13 @@ async def _export_cube_result(
tenant_id: str, tenant_id: str,
limit_source: str, limit_source: str,
title: str, title: str,
column_keys: list[str] | None = None,
) -> BaseResponse: ) -> BaseResponse:
export_data = await query_cube_export( export_data = await query_cube_export(
base_load=query, base_load=query,
tenant_id=tenant_id, tenant_id=tenant_id,
limit_source=limit_source, limit_source=limit_source,
column_keys=column_keys,
) )
filename = sanitize_filename(title or "Cube查询结果") filename = sanitize_filename(title or "Cube查询结果")
contents, sheet_count = await asyncio.to_thread( contents, sheet_count = await asyncio.to_thread(
@@ -378,6 +381,7 @@ async def _export_cube_result(
@cubeReportRouter.post("/reports/{report_id}/export") @cubeReportRouter.post("/reports/{report_id}/export")
async def export_report_data( async def export_report_data(
report_id: str, report_id: str,
request: ExportCubeReportRequest | None = None,
user_id: UUID = Depends(get_user_id_from_token), user_id: UUID = Depends(get_user_id_from_token),
): ):
report, tenant_id = await _resolve_saved_report(report_id, user_id) report, tenant_id = await _resolve_saved_report(report_id, user_id)
@@ -386,6 +390,7 @@ async def export_report_data(
tenant_id=tenant_id, tenant_id=tenant_id,
limit_source=report["limitSource"], limit_source=report["limitSource"],
title=report["title"], title=report["title"],
column_keys=request.columnKeys if request else None,
) )
@@ -440,6 +445,7 @@ async def delete_report(
@cubeReportRouter.post("/sessions/{conversation_id}/export") @cubeReportRouter.post("/sessions/{conversation_id}/export")
async def export_session_data( async def export_session_data(
conversation_id: str, conversation_id: str,
request: ExportCubeReportRequest | None = None,
user_id: UUID = Depends(get_user_id_from_token), user_id: UUID = Depends(get_user_id_from_token),
): ):
session = await get_dify_conversation(conversation_id, str(user_id)) session = await get_dify_conversation(conversation_id, str(user_id))
@@ -453,6 +459,7 @@ async def export_session_data(
tenant_id=tenant_id, tenant_id=tenant_id,
limit_source=state["limitSource"], limit_source=state["limitSource"],
title=str(session.get("title") or state.get("title") or "Cube查询结果"), title=str(session.get("title") or state.get("title") or "Cube查询结果"),
column_keys=request.columnKeys if request else None,
) )
+14
View File
@@ -903,6 +903,7 @@ async def query_cube_export(
base_load: dict[str, Any], base_load: dict[str, Any],
tenant_id: str | None, tenant_id: str | None,
limit_source: str | None, limit_source: str | None,
column_keys: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
query = deepcopy(base_load) query = deepcopy(base_load)
if not tenant_id: if not tenant_id:
@@ -925,6 +926,19 @@ async def query_cube_export(
key: _column_title(key, sql_info["aliases"], member_titles) key: _column_title(key, sql_info["aliases"], member_titles)
for key in keys for key in keys
} }
if column_keys:
selected_keys = list(dict.fromkeys(str(key).strip() for key in column_keys))
invalid_keys = [key for key in selected_keys if key not in headers]
if invalid_keys:
raise HTTPException(
status_code=422,
detail=f"导出列不存在或已失效:{', '.join(invalid_keys[:5])}",
)
headers = {key: headers[key] for key in selected_keys}
rows = [
{key: row.get(key) for key in selected_keys}
for row in rows
]
return { return {
"headers": headers, "headers": headers,
"rows": rows, "rows": rows,
+99
View File
@@ -0,0 +1,99 @@
# 开放接口统计:Kong 3.9.1 配置清单
Ktor 统计接收地址:
```text
http://<Ktor内网地址>:8089/internal/open-api/statistics/ingest
```
生产环境部署 Ktor 时必须设置:
```text
OPEN_API_LOG_TOKEN=<高强度随机Token>
```
Kong HTTP Log 中配置的 Bearer Token 必须与该环境变量一致。
## 1. Key Auth
先查询目标 Service 或 Route 上的 Key Auth 插件:
```bash
curl http://127.0.0.1:8001/services/<service-name>/plugins
```
更新插件:
```bash
curl -X PATCH http://127.0.0.1:8001/plugins/<key-auth-plugin-id> \
--data "config.hide_credentials=true" \
--data "config.key_in_header=true" \
--data "config.key_in_query=false" \
--data "config.key_in_body=false"
```
确认返回配置中:
```json
{
"hide_credentials": true,
"key_in_header": true,
"key_in_query": false,
"key_in_body": false
}
```
## 2. HTTP Log
推荐挂载到开放接口对应的 Route。若一个 Service 只有开放接口,也可挂载到 Service。
```bash
curl -X POST http://127.0.0.1:8001/routes/<route-id>/plugins \
-H "Content-Type: application/json" \
-d '{
"name": "http-log",
"config": {
"http_endpoint": "http://<Ktor内网地址>:8089/internal/open-api/statistics/ingest",
"method": "POST",
"content_type": "application/json",
"headers": {
"Authorization": "Bearer <OPEN_API_LOG_TOKEN>"
},
"timeout": 3000,
"keepalive": 60000,
"queue": {
"max_entries": 10000,
"max_batch_size": 50,
"max_coalescing_delay": 1,
"max_retry_time": 60
}
}
}'
```
建议配置范围:
- 票通 `/api/open/v1`
- AI 后端 `/api/public`
- 核心 Ktor `/traceability/public`
- 其他需要统计的开放 Route
不要将回调地址配置为经过 Kong 的公网域名,避免日志回调再次触发 HTTP Log。
## 3. 验收
每类 Route 至少执行以下测试:
1. 有效 API Key 调用成功。
2. 不带 API KeyKong 返回 401。
3. 使用错误 API KeyKong 返回 401。
4. 同一个 Consumer 从不同 IP 调用。
5. 不同 Consumer 调用同一个接口。
6. 动态发票号被归入同一个路径模板。
在前端“开放接口中心”检查:
- 有效 Key 显示 Kong Consumer。
- 无效或缺失 Key 显示匿名调用方和来源 IP。
- 小时调用量与测试次数一致。
- 页面中不出现 API Key、请求体、发票号或图片内容。
@@ -5,6 +5,7 @@ import ink.snowflake.server.controller.User
import ink.snowflake.server.controller.chat import ink.snowflake.server.controller.chat
import ink.snowflake.server.utils.plugins.configureSockets import ink.snowflake.server.utils.plugins.configureSockets
import ink.snowflake.server.controller.ImageAnalytics import ink.snowflake.server.controller.ImageAnalytics
import ink.snowflake.server.controller.OpenApiStatistics
import ink.snowflake.server.controller.Public import ink.snowflake.server.controller.Public
import ink.snowflake.server.controller.RemoteDebug import ink.snowflake.server.controller.RemoteDebug
import ink.snowflake.server.controller.Traceability import ink.snowflake.server.controller.Traceability
@@ -78,6 +79,8 @@ fun Application.module() {
// 业务-图片分析 // 业务-图片分析
ImageAnalytics() ImageAnalytics()
Traceability(appConfig) Traceability(appConfig)
// 开放接口资产与 Kong 调用统计
OpenApiStatistics(appConfig)
// 业务-公开接口 // 业务-公开接口
Public() Public()
} }
@@ -0,0 +1,148 @@
package ink.snowflake.server.controller
import ink.snowflake.server.model.request.SaveOpenApiEndpointRequest
import ink.snowflake.server.model.request.UpdateOpenApiConsumerRequest
import ink.snowflake.server.model.response.BaseResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.dao.OpenApiStatisticsDao
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import java.security.MessageDigest
import java.util.UUID
fun Application.OpenApiStatistics(config: AppConfig) {
OpenApiStatisticsDao.initSchemaAndSeed()
routing {
post("/internal/open-api/statistics/ingest") {
if (!config.openApiStatisticsEnabled) {
call.respond(HttpStatusCode.ServiceUnavailable)
return@post
}
val expected = "Bearer ${config.openApiStatisticsIngestToken}"
val actual = call.request.headers[HttpHeaders.Authorization].orEmpty()
if (
config.openApiStatisticsIngestToken.isBlank() ||
!MessageDigest.isEqual(expected.toByteArray(Charsets.UTF_8), actual.toByteArray(Charsets.UTF_8))
) {
call.respond(HttpStatusCode.Unauthorized)
return@post
}
OpenApiStatisticsDao.ingest(call.receiveText())
call.respond(HttpStatusCode.NoContent)
}
authenticate {
route("/open-api") {
get("/overview") {
val (from, to) = call.statisticsRange()
call.respond(
BaseResponse(
data = OpenApiStatisticsDao.overview(
from = from,
to = to,
serviceCode = call.request.queryParameters["serviceCode"],
category = call.request.queryParameters["category"],
)
)
)
}
get("/endpoints") {
call.respond(BaseResponse(data = OpenApiStatisticsDao.listEndpoints()))
}
post("/endpoints") {
val request = call.receive<SaveOpenApiEndpointRequest>()
if (request.name.isBlank() || request.serviceCode.isBlank() || request.pathTemplate.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse<Nothing>(status = false, message = "接口名称、所属服务和路径不能为空")
)
return@post
}
call.respond(BaseResponse(data = OpenApiStatisticsDao.saveEndpoint(null, request)))
}
put("/endpoints/{id}") {
val id = call.parameters["id"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (id == null) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse<Nothing>(status = false, message = "接口 ID 无效")
)
return@put
}
val request = call.receive<SaveOpenApiEndpointRequest>()
call.respond(BaseResponse(data = OpenApiStatisticsDao.saveEndpoint(id, request)))
}
get("/usage") {
val (from, to) = call.statisticsRange()
val endpointId = call.request.queryParameters["endpointId"]
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
call.respond(
BaseResponse(
data = OpenApiStatisticsDao.listUsage(
from = from,
to = to,
serviceCode = call.request.queryParameters["serviceCode"],
category = call.request.queryParameters["category"],
endpointId = endpointId,
callerType = call.request.queryParameters["callerType"],
)
)
)
}
get("/consumers") {
call.respond(BaseResponse(data = OpenApiStatisticsDao.listConsumers()))
}
put("/consumers/{id}") {
val id = call.parameters["id"]?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (id == null) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse<Nothing>(status = false, message = "调用方 ID 无效")
)
return@put
}
val result = OpenApiStatisticsDao.updateConsumer(id, call.receive<UpdateOpenApiConsumerRequest>())
if (result == null) {
call.respond(
HttpStatusCode.NotFound,
BaseResponse<Nothing>(status = false, message = "调用方不存在")
)
return@put
}
call.respond(BaseResponse(data = result))
}
}
}
}
}
private fun io.ktor.server.application.ApplicationCall.statisticsRange(): Pair<Instant, Instant> {
val now = Clock.System.now()
val defaultFrom = Instant.fromEpochMilliseconds(now.toEpochMilliseconds() - 7L * 24 * 3_600_000)
val from = request.queryParameters["from"]
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: defaultFrom
val to = request.queryParameters["to"]
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: now
return if (from <= to) from to to else to to from
}
@@ -0,0 +1,64 @@
package ink.snowflake.server.model.database
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.dao.id.UUIDTable
import org.jetbrains.exposed.v1.datetime.timestamp
object OpenApiEndpointsTable : UUIDTable("open_api_endpoints") {
val name = varchar("name", 160)
val serviceCode = varchar("service_code", 64)
val category = varchar("category", 80)
val httpMethod = varchar("http_method", 16).default("*")
val pathTemplate = varchar("path_template", 500)
val kongServiceId = varchar("kong_service_id", 80).default("")
val kongRouteId = varchar("kong_route_id", 80).default("")
val description = text("description").default("")
val owner = varchar("owner", 120).default("")
val monitorEnabled = bool("monitor_enabled").default(true)
val status = varchar("status", 32).default("enabled")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
uniqueIndex(serviceCode, httpMethod, pathTemplate)
}
}
object OpenApiConsumersTable : UUIDTable("open_api_consumers") {
val kongConsumerId = varchar("kong_consumer_id", 80).uniqueIndex()
val username = varchar("username", 160).default("")
val customId = varchar("custom_id", 160).default("")
val displayName = varchar("display_name", 160).default("")
val description = text("description").default("")
val firstSeenAt = timestamp("first_seen_at")
val lastSeenAt = timestamp("last_seen_at")
val status = varchar("status", 32).default("active")
}
object OpenApiUsageHourlyTable : UUIDTable("open_api_usage_hourly") {
val bucketTime = timestamp("bucket_time")
val endpointId = reference("endpoint_id", OpenApiEndpointsTable)
val callerType = varchar("caller_type", 24)
val callerKey = varchar("caller_key", 160)
val consumerId = varchar("consumer_id", 80).default("")
val consumerUsername = varchar("consumer_username", 160).default("")
val clientIp = varchar("client_ip", 80).default("")
val callCount = long("call_count").default(0)
val successCount = long("success_count").default(0)
val failureCount = long("failure_count").default(0)
val unauthorizedCount = long("unauthorized_count").default(0)
val firstCalledAt = timestamp("first_called_at")
val lastCalledAt = timestamp("last_called_at")
val updatedAt = timestamp("updated_at")
init {
uniqueIndex(bucketTime, endpointId, callerType, callerKey)
}
}
object OpenApiIngestDedupTable : Table("open_api_ingest_dedup") {
val requestId = varchar("request_id", 100)
val receivedAt = timestamp("received_at")
override val primaryKey = PrimaryKey(requestId)
}
@@ -0,0 +1,25 @@
package ink.snowflake.server.model.request
import kotlinx.serialization.Serializable
@Serializable
data class SaveOpenApiEndpointRequest(
val name: String,
val serviceCode: String,
val category: String,
val httpMethod: String = "*",
val pathTemplate: String,
val kongServiceId: String = "",
val kongRouteId: String = "",
val description: String = "",
val owner: String = "",
val monitorEnabled: Boolean = true,
val status: String = "enabled",
)
@Serializable
data class UpdateOpenApiConsumerRequest(
val displayName: String = "",
val description: String = "",
val status: String = "active",
)
@@ -0,0 +1,89 @@
package ink.snowflake.server.model.response
import kotlinx.serialization.Serializable
@Serializable
data class OpenApiEndpointResponse(
val id: String,
val name: String,
val serviceCode: String,
val category: String,
val httpMethod: String,
val pathTemplate: String,
val kongServiceId: String,
val kongRouteId: String,
val description: String,
val owner: String,
val monitorEnabled: Boolean,
val status: String,
val recentCalls: Long,
val recentConsumers: Int,
val lastCalledAt: String,
val updatedAt: String,
)
@Serializable
data class OpenApiTrendPointResponse(
val bucket: String,
val calls: Long,
val authenticatedCalls: Long,
val anonymousCalls: Long,
val failures: Long,
)
@Serializable
data class OpenApiRankItemResponse(
val key: String,
val name: String,
val value: Long,
val secondary: String = "",
)
@Serializable
data class OpenApiOverviewResponse(
val totalCalls: Long,
val authenticatedCalls: Long,
val anonymousCalls: Long,
val unauthorizedCalls: Long,
val activeConsumers: Int,
val activeIps: Int,
val successRate: Double,
val trend: List<OpenApiTrendPointResponse>,
val topEndpoints: List<OpenApiRankItemResponse>,
val topConsumers: List<OpenApiRankItemResponse>,
val categoryDistribution: List<OpenApiRankItemResponse>,
)
@Serializable
data class OpenApiConsumerResponse(
val id: String,
val kongConsumerId: String,
val username: String,
val customId: String,
val displayName: String,
val description: String,
val firstSeenAt: String,
val lastSeenAt: String,
val status: String,
val callCount: Long,
val endpointCount: Int,
val recentIp: String,
)
@Serializable
data class OpenApiUsageResponse(
val bucket: String,
val endpointId: String,
val endpointName: String,
val serviceCode: String,
val category: String,
val callerType: String,
val callerName: String,
val consumerId: String,
val clientIp: String,
val callCount: Long,
val successCount: Long,
val failureCount: Long,
val unauthorizedCount: Long,
val lastCalledAt: String,
)
@@ -33,4 +33,11 @@ class AppConfig(config: ApplicationConfig) {
?.trimEnd('/') ?.trimEnd('/')
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081" ?: "http://127.0.0.1:8081"
val openApiStatisticsEnabled: Boolean =
config.propertyOrNull("ktor.open-api-statistics.enabled")?.getString()?.toBooleanStrictOrNull() ?: true
val openApiStatisticsIngestToken: String =
System.getenv("OPEN_API_LOG_TOKEN")
?.trim()
?.takeIf { it.isNotBlank() }
?: config.propertyOrNull("ktor.open-api-statistics.ingest-token")?.getString()?.trim().orEmpty()
} }
@@ -0,0 +1,553 @@
package ink.snowflake.server.utils.dao
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import ink.snowflake.server.model.database.OpenApiConsumersTable
import ink.snowflake.server.model.database.OpenApiEndpointsTable
import ink.snowflake.server.model.database.OpenApiIngestDedupTable
import ink.snowflake.server.model.database.OpenApiUsageHourlyTable
import ink.snowflake.server.model.request.SaveOpenApiEndpointRequest
import ink.snowflake.server.model.request.UpdateOpenApiConsumerRequest
import ink.snowflake.server.model.response.OpenApiConsumerResponse
import ink.snowflake.server.model.response.OpenApiEndpointResponse
import ink.snowflake.server.model.response.OpenApiOverviewResponse
import ink.snowflake.server.model.response.OpenApiRankItemResponse
import ink.snowflake.server.model.response.OpenApiTrendPointResponse
import ink.snowflake.server.model.response.OpenApiUsageResponse
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.greaterEq
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.less
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.lessEq
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.datetime.timestampLiteral
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.UUID
import kotlin.math.round
object OpenApiStatisticsDao {
private const val HOUR_MILLIS = 3_600_000L
private const val DEDUP_RETENTION_MILLIS = 7L * 24 * HOUR_MILLIS
private data class SeedEndpoint(
val name: String,
val serviceCode: String,
val category: String,
val method: String,
val path: String,
val description: String,
)
private data class IngestEvent(
val requestId: String,
val method: String,
val uri: String,
val startedAt: Instant,
val serviceId: String,
val serviceName: String,
val routeId: String,
val consumerId: String,
val consumerUsername: String,
val consumerCustomId: String,
val clientIp: String,
val statusCode: Int,
)
private val seedEndpoints = listOf(
SeedEndpoint("蓝字发票开具", "invoice", "票通开票", "*", "/api/open/v1/blue-invoices", "创建蓝字发票"),
SeedEndpoint("蓝字发票详情", "invoice", "票通开票", "*", "/api/open/v1/blue-invoices/{invoiceCode}", "查询蓝字发票详情"),
SeedEndpoint("蓝字发票样例", "invoice", "票通开票", "*", "/api/open/v1/blue-invoices/sample/{invoiceCode}", "查询蓝字发票样例"),
SeedEndpoint("蓝字发票批次", "invoice", "票通开票", "*", "/api/open/v1/blue-invoices/batches", "创建或查询发票批次"),
SeedEndpoint("蓝字发票批次详情", "invoice", "票通开票", "*", "/api/open/v1/blue-invoices/batches/{batchCode}", "查询发票批次详情"),
SeedEndpoint("生产开票任务", "invoice", "票通开票", "*", "/api/open/v1/blue-invoice-tasks/production", "提交生产开票任务"),
SeedEndpoint("蚕茧票据指标识别", "ai-fastapi", "AI识别", "POST", "/api/public/recognize-cocoon-metrics", "识别蚕茧票据指标"),
SeedEndpoint("许可证识别", "ai-fastapi", "AI识别", "POST", "/api/public/recognize-license", "识别许可证图片"),
SeedEndpoint("蚕茧图片识别", "ai-fastapi", "AI识别", "POST", "/api/public/recognize-silkworm-cocoon", "识别蚕茧图片"),
SeedEndpoint("云哨记录分析", "ai-fastapi", "牧安云哨", "POST", "/api/public/sentinel-record-analytics", "提交牧安云哨分析任务"),
SeedEndpoint("开放批次创建", "ktor-core", "溯源开放", "POST", "/traceability/public/integration/batches", "通过开放接口创建溯源批次"),
SeedEndpoint("批次公开地址", "ktor-core", "溯源开放", "GET", "/traceability/public/integration/batches/{id}/public-url", "查询批次公开地址"),
SeedEndpoint("溯源详情查询", "ktor-core", "溯源开放", "GET", "/traceability/public/by-code/{code}", "通过批次编码查询公开信息"),
SeedEndpoint("溯源预览查询", "ktor-core", "溯源开放", "GET", "/traceability/public/preview/by-code/{code}", "查询未发布批次预览"),
SeedEndpoint("预演页查询", "ktor-core", "溯源开放", "GET", "/traceability/public/preview-page/by-code/{code}", "查询溯源预演页"),
SeedEndpoint("溯源意见反馈", "ktor-core", "溯源开放", "POST", "/traceability/public/feedback", "提交消费者反馈"),
SeedEndpoint("溯源公开页面", "ktor-core", "溯源开放", "GET", "/traceability/public/page/{code}", "访问公开溯源页面"),
SeedEndpoint("远程设备连接", "ktor-core", "运维开放", "GET", "/silk-remote/connectLocalDevice", "连接远程 ADB 设备"),
SeedEndpoint("远程设备断开", "ktor-core", "运维开放", "GET", "/silk-remote/disConnectAll", "断开远程 ADB 连接"),
)
fun initSchemaAndSeed() {
transaction {
SchemaUtils.createMissingTablesAndColumns(
OpenApiEndpointsTable,
OpenApiConsumersTable,
OpenApiUsageHourlyTable,
OpenApiIngestDedupTable,
)
val now = timestampLiteral(Clock.System.now())
seedEndpoints.forEach { seed ->
val exists = OpenApiEndpointsTable.selectAll().where {
(OpenApiEndpointsTable.serviceCode eq seed.serviceCode) and
(OpenApiEndpointsTable.httpMethod eq seed.method) and
(OpenApiEndpointsTable.pathTemplate eq seed.path)
}.any()
if (!exists) {
OpenApiEndpointsTable.insert {
it[name] = seed.name
it[serviceCode] = seed.serviceCode
it[category] = seed.category
it[httpMethod] = seed.method
it[pathTemplate] = seed.path
it[description] = seed.description
it[createdAt] = now
it[updatedAt] = now
}
}
}
}
}
@Synchronized
fun ingest(rawBody: String): Int {
val root = runCatching { JsonParser.parseString(rawBody) }.getOrNull() ?: return 0
val elements = if (root.isJsonArray) root.asJsonArray.toList() else listOf(root)
val events = elements.mapNotNull(::parseEvent)
if (events.isEmpty()) return 0
return transaction {
val now = Clock.System.now()
OpenApiIngestDedupTable.deleteWhere {
receivedAt less timestampLiteral(Instant.fromEpochMilliseconds(now.toEpochMilliseconds() - DEDUP_RETENTION_MILLIS))
}
val endpoints = OpenApiEndpointsTable.selectAll()
.where { OpenApiEndpointsTable.monitorEnabled eq true }
.toList()
var accepted = 0
events.forEach { event ->
if (OpenApiIngestDedupTable.selectAll()
.where { OpenApiIngestDedupTable.requestId eq event.requestId }
.any()
) {
return@forEach
}
val endpoint = matchEndpoint(event, endpoints) ?: return@forEach
OpenApiIngestDedupTable.insert {
it[requestId] = event.requestId
it[receivedAt] = timestampLiteral(now)
}
if (event.consumerId.isNotBlank()) {
saveConsumer(event)
}
val bucket = Instant.fromEpochMilliseconds(
event.startedAt.toEpochMilliseconds() / HOUR_MILLIS * HOUR_MILLIS
)
val callerType = if (event.consumerId.isBlank()) "anonymous" else "consumer"
val callerKey = event.consumerId.ifBlank { event.clientIp.ifBlank { "unknown" } }
val endpointId = endpoint[OpenApiEndpointsTable.id].value
val existing = OpenApiUsageHourlyTable.selectAll().where {
(OpenApiUsageHourlyTable.bucketTime eq bucket) and
(OpenApiUsageHourlyTable.endpointId eq endpointId) and
(OpenApiUsageHourlyTable.callerType eq callerType) and
(OpenApiUsageHourlyTable.callerKey eq callerKey)
}.singleOrNull()
val success = event.statusCode in 200..399
val unauthorized = event.statusCode == 401 || event.statusCode == 403
if (existing == null) {
OpenApiUsageHourlyTable.insert {
it[bucketTime] = timestampLiteral(bucket)
it[OpenApiUsageHourlyTable.endpointId] = endpointId
it[OpenApiUsageHourlyTable.callerType] = callerType
it[OpenApiUsageHourlyTable.callerKey] = callerKey
it[consumerId] = event.consumerId
it[consumerUsername] = event.consumerUsername
it[clientIp] = event.clientIp
it[callCount] = 1
it[successCount] = if (success) 1 else 0
it[failureCount] = if (success) 0 else 1
it[unauthorizedCount] = if (unauthorized) 1 else 0
it[firstCalledAt] = timestampLiteral(event.startedAt)
it[lastCalledAt] = timestampLiteral(event.startedAt)
it[updatedAt] = timestampLiteral(now)
}
} else {
OpenApiUsageHourlyTable.update({ OpenApiUsageHourlyTable.id eq existing[OpenApiUsageHourlyTable.id].value }) {
it[callCount] = existing[OpenApiUsageHourlyTable.callCount] + 1
it[successCount] = existing[OpenApiUsageHourlyTable.successCount] + if (success) 1 else 0
it[failureCount] = existing[OpenApiUsageHourlyTable.failureCount] + if (success) 0 else 1
it[unauthorizedCount] = existing[OpenApiUsageHourlyTable.unauthorizedCount] + if (unauthorized) 1 else 0
it[lastCalledAt] = timestampLiteral(maxOf(existing[OpenApiUsageHourlyTable.lastCalledAt], event.startedAt))
it[updatedAt] = timestampLiteral(now)
}
}
accepted++
}
accepted
}
}
fun listEndpoints(): List<OpenApiEndpointResponse> = transaction {
val recentFrom = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() - 24 * HOUR_MILLIS)
val recentRows = OpenApiUsageHourlyTable.selectAll()
.where { OpenApiUsageHourlyTable.bucketTime greaterEq timestampLiteral(recentFrom) }
.toList()
.groupBy { it[OpenApiUsageHourlyTable.endpointId].value }
OpenApiEndpointsTable.selectAll()
.orderBy(OpenApiEndpointsTable.serviceCode, SortOrder.ASC)
.map { row ->
val usage = recentRows[row[OpenApiEndpointsTable.id].value].orEmpty()
endpointResponse(row, usage)
}
}
fun saveEndpoint(id: UUID?, request: SaveOpenApiEndpointRequest): OpenApiEndpointResponse = transaction {
val now = timestampLiteral(Clock.System.now())
val endpointId = id ?: OpenApiEndpointsTable.insertAndGetId {
it[name] = request.name.trim()
it[serviceCode] = request.serviceCode.trim()
it[category] = request.category.trim()
it[httpMethod] = request.httpMethod.trim().uppercase().ifBlank { "*" }
it[pathTemplate] = normalizePath(request.pathTemplate)
it[kongServiceId] = request.kongServiceId.trim()
it[kongRouteId] = request.kongRouteId.trim()
it[description] = request.description.trim()
it[owner] = request.owner.trim()
it[monitorEnabled] = request.monitorEnabled
it[status] = request.status.trim().ifBlank { "enabled" }
it[createdAt] = now
it[updatedAt] = now
}.value
if (id != null) {
OpenApiEndpointsTable.update({ OpenApiEndpointsTable.id eq id }) {
it[name] = request.name.trim()
it[serviceCode] = request.serviceCode.trim()
it[category] = request.category.trim()
it[httpMethod] = request.httpMethod.trim().uppercase().ifBlank { "*" }
it[pathTemplate] = normalizePath(request.pathTemplate)
it[kongServiceId] = request.kongServiceId.trim()
it[kongRouteId] = request.kongRouteId.trim()
it[description] = request.description.trim()
it[owner] = request.owner.trim()
it[monitorEnabled] = request.monitorEnabled
it[status] = request.status.trim().ifBlank { "enabled" }
it[updatedAt] = now
}
}
val row = OpenApiEndpointsTable.selectAll()
.where { OpenApiEndpointsTable.id eq endpointId }
.single()
endpointResponse(row, emptyList())
}
fun overview(
from: Instant,
to: Instant,
serviceCode: String?,
category: String?,
): OpenApiOverviewResponse = transaction {
val endpointRows = OpenApiEndpointsTable.selectAll().toList()
val endpointMap = endpointRows.associateBy { it[OpenApiEndpointsTable.id].value }
val allowedEndpointIds = endpointRows.asSequence()
.filter { serviceCode.isNullOrBlank() || it[OpenApiEndpointsTable.serviceCode] == serviceCode }
.filter { category.isNullOrBlank() || it[OpenApiEndpointsTable.category] == category }
.map { it[OpenApiEndpointsTable.id].value }
.toSet()
val rows = usageRows(from, to).filter {
it[OpenApiUsageHourlyTable.endpointId].value in allowedEndpointIds
}
val total = rows.sumOf { it[OpenApiUsageHourlyTable.callCount] }
val authenticated = rows.filter { it[OpenApiUsageHourlyTable.callerType] == "consumer" }
.sumOf { it[OpenApiUsageHourlyTable.callCount] }
val anonymous = total - authenticated
val unauthorized = rows.sumOf { it[OpenApiUsageHourlyTable.unauthorizedCount] }
val successes = rows.sumOf { it[OpenApiUsageHourlyTable.successCount] }
val useHourly = to.toEpochMilliseconds() - from.toEpochMilliseconds() <= 48 * HOUR_MILLIS
val trend = rows.groupBy {
val text = it[OpenApiUsageHourlyTable.bucketTime].toString()
if (useHourly) text.take(13) + ":00:00Z" else text.take(10)
}.toSortedMap().map { (bucket, items) ->
OpenApiTrendPointResponse(
bucket = bucket,
calls = items.sumOf { it[OpenApiUsageHourlyTable.callCount] },
authenticatedCalls = items.filter { it[OpenApiUsageHourlyTable.callerType] == "consumer" }
.sumOf { it[OpenApiUsageHourlyTable.callCount] },
anonymousCalls = items.filter { it[OpenApiUsageHourlyTable.callerType] == "anonymous" }
.sumOf { it[OpenApiUsageHourlyTable.callCount] },
failures = items.sumOf { it[OpenApiUsageHourlyTable.failureCount] },
)
}
val topEndpoints = rows.groupBy { it[OpenApiUsageHourlyTable.endpointId].value }
.map { (id, items) ->
val endpoint = endpointMap[id]
OpenApiRankItemResponse(
key = id.toString(),
name = endpoint?.get(OpenApiEndpointsTable.name) ?: "未知接口",
value = items.sumOf { it[OpenApiUsageHourlyTable.callCount] },
secondary = endpoint?.get(OpenApiEndpointsTable.serviceCode).orEmpty(),
)
}.sortedByDescending { it.value }.take(8)
val topConsumers = rows.groupBy {
if (it[OpenApiUsageHourlyTable.callerType] == "consumer") {
it[OpenApiUsageHourlyTable.consumerId]
} else {
"ip:${it[OpenApiUsageHourlyTable.clientIp]}"
}
}.map { (key, items) ->
val first = items.first()
val name = if (first[OpenApiUsageHourlyTable.callerType] == "consumer") {
first[OpenApiUsageHourlyTable.consumerUsername].ifBlank { key }
} else {
"匿名 · ${first[OpenApiUsageHourlyTable.clientIp].ifBlank { "未知 IP" }}"
}
OpenApiRankItemResponse(key, name, items.sumOf { it[OpenApiUsageHourlyTable.callCount] })
}.sortedByDescending { it.value }.take(8)
val categoryDistribution = rows.groupBy {
endpointMap[it[OpenApiUsageHourlyTable.endpointId].value]?.get(OpenApiEndpointsTable.category) ?: "其他"
}.map { (name, items) ->
OpenApiRankItemResponse(name, name, items.sumOf { it[OpenApiUsageHourlyTable.callCount] })
}.sortedByDescending { it.value }
OpenApiOverviewResponse(
totalCalls = total,
authenticatedCalls = authenticated,
anonymousCalls = anonymous,
unauthorizedCalls = unauthorized,
activeConsumers = rows.map { it[OpenApiUsageHourlyTable.consumerId] }.filter { it.isNotBlank() }.distinct().size,
activeIps = rows.map { it[OpenApiUsageHourlyTable.clientIp] }.filter { it.isNotBlank() }.distinct().size,
successRate = if (total == 0L) 100.0 else round(successes * 10_000.0 / total) / 100.0,
trend = trend,
topEndpoints = topEndpoints,
topConsumers = topConsumers,
categoryDistribution = categoryDistribution,
)
}
fun listConsumers(): List<OpenApiConsumerResponse> = transaction {
val usageByConsumer = OpenApiUsageHourlyTable.selectAll()
.where { OpenApiUsageHourlyTable.consumerId neq "" }
.toList()
.groupBy { it[OpenApiUsageHourlyTable.consumerId] }
OpenApiConsumersTable.selectAll()
.orderBy(OpenApiConsumersTable.lastSeenAt, SortOrder.DESC)
.map { row ->
val usage = usageByConsumer[row[OpenApiConsumersTable.kongConsumerId]].orEmpty()
OpenApiConsumerResponse(
id = row[OpenApiConsumersTable.id].value.toString(),
kongConsumerId = row[OpenApiConsumersTable.kongConsumerId],
username = row[OpenApiConsumersTable.username],
customId = row[OpenApiConsumersTable.customId],
displayName = row[OpenApiConsumersTable.displayName],
description = row[OpenApiConsumersTable.description],
firstSeenAt = row[OpenApiConsumersTable.firstSeenAt].toString(),
lastSeenAt = row[OpenApiConsumersTable.lastSeenAt].toString(),
status = row[OpenApiConsumersTable.status],
callCount = usage.sumOf { it[OpenApiUsageHourlyTable.callCount] },
endpointCount = usage.map { it[OpenApiUsageHourlyTable.endpointId].value }.distinct().size,
recentIp = usage.maxByOrNull { it[OpenApiUsageHourlyTable.lastCalledAt] }
?.get(OpenApiUsageHourlyTable.clientIp).orEmpty(),
)
}
}
fun updateConsumer(id: UUID, request: UpdateOpenApiConsumerRequest): OpenApiConsumerResponse? {
transaction {
OpenApiConsumersTable.update({ OpenApiConsumersTable.id eq id }) {
it[displayName] = request.displayName.trim()
it[description] = request.description.trim()
it[status] = request.status.trim().ifBlank { "active" }
}
}
return listConsumers().firstOrNull { it.id == id.toString() }
}
fun listUsage(
from: Instant,
to: Instant,
serviceCode: String?,
category: String?,
endpointId: UUID?,
callerType: String?,
): List<OpenApiUsageResponse> = transaction {
val endpointRows = OpenApiEndpointsTable.selectAll().toList()
val endpointMap = endpointRows.associateBy { it[OpenApiEndpointsTable.id].value }
usageRows(from, to).asSequence()
.filter { endpointId == null || it[OpenApiUsageHourlyTable.endpointId].value == endpointId }
.filter { callerType.isNullOrBlank() || it[OpenApiUsageHourlyTable.callerType] == callerType }
.filter {
val endpoint = endpointMap[it[OpenApiUsageHourlyTable.endpointId].value]
(serviceCode.isNullOrBlank() || endpoint?.get(OpenApiEndpointsTable.serviceCode) == serviceCode) &&
(category.isNullOrBlank() || endpoint?.get(OpenApiEndpointsTable.category) == category)
}
.sortedByDescending { it[OpenApiUsageHourlyTable.bucketTime] }
.take(1_000)
.map { row ->
val endpoint = endpointMap[row[OpenApiUsageHourlyTable.endpointId].value]
val callerName = if (row[OpenApiUsageHourlyTable.callerType] == "consumer") {
row[OpenApiUsageHourlyTable.consumerUsername]
.ifBlank { row[OpenApiUsageHourlyTable.consumerId] }
} else {
"匿名调用方"
}
OpenApiUsageResponse(
bucket = row[OpenApiUsageHourlyTable.bucketTime].toString(),
endpointId = row[OpenApiUsageHourlyTable.endpointId].value.toString(),
endpointName = endpoint?.get(OpenApiEndpointsTable.name) ?: "未知接口",
serviceCode = endpoint?.get(OpenApiEndpointsTable.serviceCode).orEmpty(),
category = endpoint?.get(OpenApiEndpointsTable.category).orEmpty(),
callerType = row[OpenApiUsageHourlyTable.callerType],
callerName = callerName,
consumerId = row[OpenApiUsageHourlyTable.consumerId],
clientIp = row[OpenApiUsageHourlyTable.clientIp],
callCount = row[OpenApiUsageHourlyTable.callCount],
successCount = row[OpenApiUsageHourlyTable.successCount],
failureCount = row[OpenApiUsageHourlyTable.failureCount],
unauthorizedCount = row[OpenApiUsageHourlyTable.unauthorizedCount],
lastCalledAt = row[OpenApiUsageHourlyTable.lastCalledAt].toString(),
)
}.toList()
}
private fun usageRows(from: Instant, to: Instant): List<ResultRow> {
return OpenApiUsageHourlyTable.selectAll().where {
(OpenApiUsageHourlyTable.bucketTime greaterEq timestampLiteral(from)) and
(OpenApiUsageHourlyTable.bucketTime lessEq timestampLiteral(to))
}.toList()
}
private fun endpointResponse(row: ResultRow, usage: List<ResultRow>): OpenApiEndpointResponse {
return OpenApiEndpointResponse(
id = row[OpenApiEndpointsTable.id].value.toString(),
name = row[OpenApiEndpointsTable.name],
serviceCode = row[OpenApiEndpointsTable.serviceCode],
category = row[OpenApiEndpointsTable.category],
httpMethod = row[OpenApiEndpointsTable.httpMethod],
pathTemplate = row[OpenApiEndpointsTable.pathTemplate],
kongServiceId = row[OpenApiEndpointsTable.kongServiceId],
kongRouteId = row[OpenApiEndpointsTable.kongRouteId],
description = row[OpenApiEndpointsTable.description],
owner = row[OpenApiEndpointsTable.owner],
monitorEnabled = row[OpenApiEndpointsTable.monitorEnabled],
status = row[OpenApiEndpointsTable.status],
recentCalls = usage.sumOf { it[OpenApiUsageHourlyTable.callCount] },
recentConsumers = usage.map { it[OpenApiUsageHourlyTable.consumerId] }.filter { it.isNotBlank() }.distinct().size,
lastCalledAt = usage.maxByOrNull { it[OpenApiUsageHourlyTable.lastCalledAt] }
?.get(OpenApiUsageHourlyTable.lastCalledAt)?.toString().orEmpty(),
updatedAt = row[OpenApiEndpointsTable.updatedAt].toString(),
)
}
private fun saveConsumer(event: IngestEvent) {
val row = OpenApiConsumersTable.selectAll()
.where { OpenApiConsumersTable.kongConsumerId eq event.consumerId }
.singleOrNull()
if (row == null) {
OpenApiConsumersTable.insert {
it[kongConsumerId] = event.consumerId
it[username] = event.consumerUsername
it[customId] = event.consumerCustomId
it[displayName] = event.consumerUsername
it[firstSeenAt] = timestampLiteral(event.startedAt)
it[lastSeenAt] = timestampLiteral(event.startedAt)
}
} else {
OpenApiConsumersTable.update({ OpenApiConsumersTable.id eq row[OpenApiConsumersTable.id].value }) {
it[username] = event.consumerUsername
it[customId] = event.consumerCustomId
it[lastSeenAt] = timestampLiteral(maxOf(row[OpenApiConsumersTable.lastSeenAt], event.startedAt))
}
}
}
private fun matchEndpoint(event: IngestEvent, endpoints: List<ResultRow>): ResultRow? {
val path = normalizePath(event.uri.substringBefore('?'))
return endpoints.asSequence()
.filter {
val method = it[OpenApiEndpointsTable.httpMethod]
method == "*" || method.equals(event.method, ignoreCase = true)
}
.filter { pathMatches(it[OpenApiEndpointsTable.pathTemplate], path) }
.maxByOrNull {
var score = it[OpenApiEndpointsTable.pathTemplate].split('/').count { segment ->
segment.isNotBlank() && !(segment.startsWith("{") && segment.endsWith("}"))
} * 10
if (event.routeId.isNotBlank() && it[OpenApiEndpointsTable.kongRouteId] == event.routeId) score += 1_000
if (event.serviceId.isNotBlank() && it[OpenApiEndpointsTable.kongServiceId] == event.serviceId) score += 500
if (event.serviceName.contains(it[OpenApiEndpointsTable.serviceCode], ignoreCase = true)) score += 100
score
}
}
private fun pathMatches(template: String, actual: String): Boolean {
val templateParts = normalizePath(template).trim('/').split('/').filter { it.isNotBlank() }
val actualParts = normalizePath(actual).trim('/').split('/').filter { it.isNotBlank() }
if (templateParts.size != actualParts.size) return false
return templateParts.zip(actualParts).all { (expected, value) ->
(expected.startsWith("{") && expected.endsWith("}")) || expected == value
}
}
private fun normalizePath(path: String): String {
val normalized = "/" + path.trim().trim('/').replace(Regex("/+"), "/")
return if (normalized == "/") normalized else normalized.trimEnd('/')
}
private fun parseEvent(element: JsonElement): IngestEvent? {
if (!element.isJsonObject) return null
val root = element.asJsonObject
val request = root.objectOrNull("request") ?: return null
val response = root.objectOrNull("response")
val service = root.objectOrNull("service")
val route = root.objectOrNull("route")
val consumer = root.objectOrNull("consumer")
val requestId = request.string("id")
val uri = request.string("uri")
if (requestId.isBlank() || uri.isBlank()) return null
val startedAtMillis = root.long("started_at").takeIf { it > 0 } ?: Clock.System.now().toEpochMilliseconds()
return IngestEvent(
requestId = requestId,
method = request.string("method").uppercase().ifBlank { "GET" },
uri = uri,
startedAt = Instant.fromEpochMilliseconds(startedAtMillis),
serviceId = service?.string("id").orEmpty(),
serviceName = service?.string("name").orEmpty(),
routeId = route?.string("id").orEmpty(),
consumerId = consumer?.string("id").orEmpty(),
consumerUsername = consumer?.string("username").orEmpty(),
consumerCustomId = consumer?.string("custom_id").orEmpty(),
clientIp = root.string("client_ip"),
statusCode = response?.int("status") ?: 0,
)
}
private fun JsonObject.objectOrNull(name: String): JsonObject? {
val value = get(name) ?: return null
return if (value.isJsonObject) value.asJsonObject else null
}
private fun JsonObject.string(name: String): String {
val value = get(name) ?: return ""
return runCatching { if (value.isJsonNull) "" else value.asString }.getOrDefault("")
}
private fun JsonObject.long(name: String): Long {
val value = get(name) ?: return 0
return runCatching { value.asLong }.getOrDefault(0)
}
private fun JsonObject.int(name: String): Int {
val value = get(name) ?: return 0
return runCatching { value.asInt }.getOrDefault(0)
}
}
+5
View File
@@ -44,3 +44,8 @@ ktor:
# public-preview-base-url: "http://127.0.0.1:8081" # 开发测试用 # public-preview-base-url: "http://127.0.0.1:8081" # 开发测试用
public-preview-base-url: "https://trace.bbitcn.net" # 生产环境用 public-preview-base-url: "https://trace.bbitcn.net" # 生产环境用
open-api-statistics:
enabled: true
# 生产环境请通过 OPEN_API_LOG_TOKEN 环境变量覆盖
ingest-token: "change-me-before-deploy"
+16 -4
View File
@@ -108,6 +108,10 @@ export interface CubeReportExportResult {
url: string; url: string;
} }
export interface CubeReportExportRequest {
columnKeys: string[];
}
export type CubeReportStreamEvent = export type CubeReportStreamEvent =
| { content: string; type: 'message_delta' | 'message_replace' } | { content: string; type: 'message_delta' | 'message_replace' }
| { | {
@@ -233,7 +237,7 @@ export async function getCubeReportSession(id: string) {
export async function getCubeReportData(id: string, params: { page: number }) { export async function getCubeReportData(id: string, params: { page: number }) {
return pyRequestClient.get<CubeReportPage>( return pyRequestClient.get<CubeReportPage>(
`/llm/cube-report/sessions/${id}/data`, `/llm/cube-report/sessions/${id}/data`,
{ params }, { params, showErrorMessage: false },
); );
} }
@@ -243,19 +247,27 @@ export async function getCubeSavedReportData(
) { ) {
return pyRequestClient.get<CubeReportPage>( return pyRequestClient.get<CubeReportPage>(
`/llm/cube-report/reports/${id}/data`, `/llm/cube-report/reports/${id}/data`,
{ params }, { params, showErrorMessage: false },
); );
} }
export async function exportCubeReportSession(id: string) { export async function exportCubeReportSession(
id: string,
data: CubeReportExportRequest,
) {
return pyRequestClient.post<CubeReportExportResult>( return pyRequestClient.post<CubeReportExportResult>(
`/llm/cube-report/sessions/${id}/export`, `/llm/cube-report/sessions/${id}/export`,
data,
); );
} }
export async function exportCubeSavedReport(id: string) { export async function exportCubeSavedReport(
id: string,
data: CubeReportExportRequest,
) {
return pyRequestClient.post<CubeReportExportResult>( return pyRequestClient.post<CubeReportExportResult>(
`/llm/cube-report/reports/${id}/export`, `/llm/cube-report/reports/${id}/export`,
data,
); );
} }
@@ -0,0 +1,154 @@
import { requestClient } from '#/api/request';
export namespace OpenApiCenterApi {
export interface Endpoint {
id: string;
name: string;
serviceCode: string;
category: string;
httpMethod: string;
pathTemplate: string;
kongServiceId: string;
kongRouteId: string;
description: string;
owner: string;
monitorEnabled: boolean;
status: string;
recentCalls: number;
recentConsumers: number;
lastCalledAt: string;
updatedAt: string;
}
export interface TrendPoint {
bucket: string;
calls: number;
authenticatedCalls: number;
anonymousCalls: number;
failures: number;
}
export interface RankItem {
key: string;
name: string;
value: number;
secondary: string;
}
export interface Overview {
totalCalls: number;
authenticatedCalls: number;
anonymousCalls: number;
unauthorizedCalls: number;
activeConsumers: number;
activeIps: number;
successRate: number;
trend: TrendPoint[];
topEndpoints: RankItem[];
topConsumers: RankItem[];
categoryDistribution: RankItem[];
}
export interface Consumer {
id: string;
kongConsumerId: string;
username: string;
customId: string;
displayName: string;
description: string;
firstSeenAt: string;
lastSeenAt: string;
status: string;
callCount: number;
endpointCount: number;
recentIp: string;
}
export interface Usage {
bucket: string;
endpointId: string;
endpointName: string;
serviceCode: string;
category: string;
callerType: string;
callerName: string;
consumerId: string;
clientIp: string;
callCount: number;
successCount: number;
failureCount: number;
unauthorizedCount: number;
lastCalledAt: string;
}
export interface Query {
from?: string;
to?: string;
serviceCode?: string;
category?: string;
endpointId?: string;
callerType?: string;
}
export type SaveEndpoint = Pick<
Endpoint,
| 'category'
| 'description'
| 'httpMethod'
| 'kongRouteId'
| 'kongServiceId'
| 'monitorEnabled'
| 'name'
| 'owner'
| 'pathTemplate'
| 'serviceCode'
| 'status'
>;
}
export function getOpenApiOverview(params: OpenApiCenterApi.Query) {
return requestClient.get<OpenApiCenterApi.Overview>('/open-api/overview', {
params,
});
}
export function getOpenApiEndpoints() {
return requestClient.get<OpenApiCenterApi.Endpoint[]>('/open-api/endpoints');
}
export function createOpenApiEndpoint(data: OpenApiCenterApi.SaveEndpoint) {
return requestClient.post<OpenApiCenterApi.Endpoint>(
'/open-api/endpoints',
data,
);
}
export function updateOpenApiEndpoint(
id: string,
data: OpenApiCenterApi.SaveEndpoint,
) {
return requestClient.put<OpenApiCenterApi.Endpoint>(
`/open-api/endpoints/${id}`,
data,
);
}
export function getOpenApiConsumers() {
return requestClient.get<OpenApiCenterApi.Consumer[]>('/open-api/consumers');
}
export function updateOpenApiConsumer(
id: string,
data: Pick<OpenApiCenterApi.Consumer, 'description' | 'displayName' | 'status'>,
) {
return requestClient.put<OpenApiCenterApi.Consumer>(
`/open-api/consumers/${id}`,
data,
);
}
export function getOpenApiUsage(params: OpenApiCenterApi.Query) {
return requestClient.get<OpenApiCenterApi.Usage[]>('/open-api/usage', {
params,
});
}
+5 -1
View File
@@ -101,10 +101,14 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里 // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor( client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => { errorMessageResponseInterceptor((msg: string, error) => {
if (error?.config?.showErrorMessage === false) return;
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message // 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {}; const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? ''; const detailMessage =
typeof responseData?.detail === 'string' ? responseData.detail : '';
const errorMessage =
detailMessage || responseData?.error || responseData?.message || '';
// 如果没有错误信息,则会根据状态码进行提示 // 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg); message.error(errorMessage || msg);
}), }),
@@ -0,0 +1,18 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
component: () => import('#/views/open-api/index.vue'),
meta: {
authority: ['manager'],
icon: 'lucide:waypoints',
keepAlive: true,
order: 3,
title: '开放接口中心',
},
name: 'OpenApiCenter',
path: '/open-api/center',
},
];
export default routes;
@@ -0,0 +1,523 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CubeReportColumn } from '#/api';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Input, message, Segmented, Tooltip } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
type CubeRow = Record<string, unknown>;
type GridDensity = 'comfortable' | 'compact' | 'standard';
interface GridColumnInfo {
field: string;
title?: string;
visible?: boolean;
}
interface GridCellEvent {
column: GridColumnInfo;
row: CubeRow;
}
const props = defineProps<{
columns: CubeReportColumn[];
loading?: boolean;
rows: CubeRow[];
storageKey: string;
}>();
const keyword = ref('');
const density = ref<GridDensity>('standard');
const selectedCell = ref<null | {
field: string;
title: string;
value: unknown;
}>(null);
const gridHost = ref<HTMLElement | null>(null);
let resizeObserver: null | ResizeObserver = null;
let resizeFrame: number | undefined;
const densityOptions = [
{ label: '紧凑', value: 'compact' },
{ label: '标准', value: 'standard' },
{ label: '宽松', value: 'comfortable' },
];
const densityStorageKey = computed(
() => `cube-report-grid-density:${props.storageKey}`,
);
const gridId = computed(
() =>
`cube-report-result-${props.storageKey.replaceAll(/[^\w-]/g, '-').slice(0, 100)}`,
);
const gridSize = computed(() => {
if (density.value === 'compact') return 'mini' as const;
if (density.value === 'comfortable') return 'medium' as const;
return 'small' as const;
});
const rowHeight = computed(() => {
if (density.value === 'compact') return 30;
if (density.value === 'comfortable') return 44;
return 36;
});
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase());
const visibleRows = computed(() => {
if (!normalizedKeyword.value) return props.rows;
return props.rows.filter((row) =>
props.columns.some((column) =>
normalizeSearchValue(row[column.key]).includes(normalizedKeyword.value),
),
);
});
function normalizeSearchValue(value: unknown) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
try {
return JSON.stringify(value).toLowerCase();
} catch {
return String(value).toLowerCase();
}
}
return String(value).toLowerCase();
}
function displayValue(value: unknown) {
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'object') {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
function isNumericColumn(column: CubeReportColumn) {
const sample = props.rows.find((row) => {
const value = row[column.key];
return value !== null && value !== undefined && value !== '';
})?.[column.key];
return typeof sample === 'number';
}
function initialColumnWidth(column: CubeReportColumn) {
const titleWidth = [...column.title].reduce(
(width, character) =>
width + ((character.codePointAt(0) ?? 0) > 255 ? 14 : 8),
36,
);
return Math.min(280, Math.max(130, titleWidth));
}
const vxeColumns = computed<VxeTableGridOptions<CubeRow>['columns']>(() => [
{
align: 'center',
fixed: 'left',
title: '#',
type: 'seq',
width: 54,
},
...props.columns.map((column) => ({
align: isNumericColumn(column) ? ('right' as const) : ('left' as const),
field: column.key,
formatter: ({ cellValue }: { cellValue: unknown }) =>
displayValue(cellValue),
minWidth: initialColumnWidth(column),
sortable: true,
title: column.title,
})),
]);
const gridOptions = computed<VxeTableGridOptions<CubeRow>>(() => ({
id: gridId.value,
align: 'left',
border: 'inner',
columnConfig: {
resizable: true,
useKey: true,
},
columns: vxeColumns.value,
customConfig: {
allowFixed: true,
allowResizable: true,
allowSort: true,
allowVisible: true,
immediate: true,
mode: 'simple',
storage: true,
},
data: visibleRows.value,
height: '100%',
highlightCurrentColumn: true,
highlightHoverRow: true,
keyboardConfig: {
isArrow: true,
isEnter: true,
isTab: true,
},
loading: props.loading,
mouseConfig: {
selected: true,
},
pagerConfig: {
enabled: false,
},
rowConfig: {
height: rowHeight.value,
keyField: '__rowKey',
useKey: true,
},
scrollX: {
enabled: props.columns.length > 12,
gt: 12,
},
scrollY: {
enabled: visibleRows.value.length > 40,
gt: 40,
},
showHeaderOverflow: 'tooltip',
showOverflow: 'tooltip',
size: gridSize.value,
sortConfig: {
multiple: true,
remote: false,
trigger: 'cell',
},
stripe: true,
toolbarConfig: {
custom: true,
zoom: true,
},
}));
const [Grid, gridApi] = useVbenVxeGrid<CubeRow>({
gridEvents: {
cellClick: handleCellClick,
cellDblclick: handleCellDoubleClick,
},
gridOptions: gridOptions.value,
});
function handleCellClick(event: GridCellEvent) {
const field = String(event.column.field || '');
if (!field) {
selectedCell.value = null;
return;
}
selectedCell.value = {
field,
title: String(event.column.title || field),
value: event.row[field],
};
}
function handleCellDoubleClick(event: GridCellEvent) {
handleCellClick(event);
void copySelectedCell();
}
function escapeTabularValue(value: unknown) {
return displayValue(value).replaceAll('\t', ' ').replaceAll(/\r?\n/g, ' ');
}
async function writeClipboard(text: string, successText: string) {
try {
await navigator.clipboard.writeText(text);
message.success(successText);
} catch {
message.error('复制失败,请检查浏览器剪贴板权限');
}
}
async function copySelectedCell() {
if (!selectedCell.value) return;
await writeClipboard(
displayValue(selectedCell.value.value),
`已复制“${selectedCell.value.title}”单元格`,
);
}
function getVisibleDataColumns() {
const renderedColumns = gridApi.grid?.getColumns?.() ?? [];
return (renderedColumns as GridColumnInfo[]).filter(
(column) => column.visible !== false && Boolean(column.field),
);
}
function getExportColumns() {
return getVisibleDataColumns().map((column) => ({
key: column.field,
title: String(column.title || column.field),
}));
}
defineExpose({ getExportColumns });
async function copyCurrentPage() {
const columns = getVisibleDataColumns();
if (columns.length === 0 || visibleRows.value.length === 0) return;
const header = columns.map((column) => escapeTabularValue(column.title));
const body = visibleRows.value.map((row) =>
columns.map((column) => escapeTabularValue(row[column.field])).join('\t'),
);
await writeClipboard(
[header.join('\t'), ...body].join('\n'),
`已复制当前页 ${visibleRows.value.length}`,
);
}
function restoreDensity() {
const value = localStorage.getItem(densityStorageKey.value);
if (['comfortable', 'compact', 'standard'].includes(String(value))) {
density.value = value as GridDensity;
}
}
function observeGridHost() {
resizeObserver?.disconnect();
if (!gridHost.value) return;
resizeObserver = new ResizeObserver(() => {
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
void gridApi.grid?.recalculate?.();
});
});
resizeObserver.observe(gridHost.value);
}
watch(gridOptions, (options) => gridApi.setGridOptions(options), {
deep: true,
});
watch(density, (value) => {
localStorage.setItem(densityStorageKey.value, value);
void nextTick(() => gridApi.grid?.recalculate?.());
});
watch(
() => props.storageKey,
() => {
keyword.value = '';
selectedCell.value = null;
restoreDensity();
},
);
onMounted(() => {
restoreDensity();
observeGridHost();
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
if (resizeFrame) cancelAnimationFrame(resizeFrame);
});
</script>
<template>
<div ref="gridHost" class="cube-data-grid">
<Grid :grid-options="gridOptions">
<template #toolbar-actions>
<div class="grid-toolbar-left">
<Input
v-model:value="keyword"
allow-clear
class="page-search"
placeholder="搜索当前页全部列"
>
<template #prefix>
<IconifyIcon icon="lucide:search" />
</template>
</Input>
<Tooltip title="搜索与表头排序只作用于当前已加载页面">
<span class="page-scope-badge">
<IconifyIcon icon="lucide:info" />
本页 {{ visibleRows.length }}/{{ rows.length }}
</span>
</Tooltip>
</div>
</template>
<template #toolbar-tools>
<div class="grid-toolbar-tools">
<Tooltip title="双击单元格也可以复制">
<Button
class="grid-tool-button"
size="small"
:disabled="!selectedCell"
@click="copySelectedCell"
>
<template #icon>
<IconifyIcon icon="lucide:copy" />
</template>
复制单元格
</Button>
</Tooltip>
<Tooltip title="复制当前页可见列,可直接粘贴到 Excel">
<Button
class="grid-tool-button"
size="small"
:disabled="visibleRows.length === 0"
@click="copyCurrentPage"
>
<template #icon>
<IconifyIcon icon="lucide:clipboard-copy" />
</template>
复制本页
</Button>
</Tooltip>
<Segmented
v-model:value="density"
class="density-switch"
:options="densityOptions"
size="small"
/>
</div>
</template>
<template #empty>
<div class="grid-empty">
<IconifyIcon icon="lucide:search-x" />
<span>{{ keyword ? '当前页没有匹配数据' : '暂无数据' }}</span>
<Button v-if="keyword" size="small" type="link" @click="keyword = ''">
清除搜索
</Button>
</div>
</template>
</Grid>
</div>
</template>
<style scoped>
.cube-data-grid {
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
overflow: hidden;
background: #fff;
}
.cube-data-grid :deep(.bg-card) {
border-radius: 0;
}
.cube-data-grid :deep(.vxe-grid) {
padding: 0 !important;
}
.cube-data-grid :deep(.vxe-toolbar) {
min-height: 44px;
padding: 6px 10px;
background: linear-gradient(180deg, #fff, #fbfdff);
border-bottom: 1px solid #e8edf4;
}
.cube-data-grid :deep(.vxe-table--header-wrapper) {
color: #334155;
font-weight: 650;
background: #f7f9fc;
}
.cube-data-grid :deep(.vxe-header--column) {
background: #f7f9fc;
border-color: #e5eaf1;
}
.cube-data-grid :deep(.vxe-body--column) {
color: #334155;
border-color: #edf1f5;
}
.cube-data-grid :deep(.row--stripe .vxe-body--column) {
background: #fafcff;
}
.cube-data-grid :deep(.row--hover .vxe-body--column) {
background: #eff6ff !important;
}
.cube-data-grid :deep(.col--current) {
background: #eef6ff !important;
}
.cube-data-grid :deep(.col--selected) {
box-shadow: inset 0 0 0 1px #60a5fa;
}
.cube-data-grid :deep(.vxe-cell--sort) {
color: #94a3b8;
}
.cube-data-grid :deep(.is--active .vxe-sort--asc-btn),
.cube-data-grid :deep(.is--active .vxe-sort--desc-btn) {
color: #2563eb;
}
.grid-toolbar-left,
.grid-toolbar-tools {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.page-search {
width: min(260px, 24vw);
border-color: #dbe3ee;
border-radius: 8px;
}
.page-search :deep(.ant-input-prefix) {
color: #94a3b8;
}
.page-scope-badge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 5px;
padding: 3px 8px;
color: #64748b;
font-size: 11px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 99px;
}
.grid-tool-button {
color: #475569;
border-color: #dbe3ee;
border-radius: 7px;
}
.grid-tool-button:hover:not(:disabled) {
color: #1d4ed8;
border-color: #93c5fd;
}
.density-switch {
flex-shrink: 0;
padding: 2px;
background: #f1f5f9;
}
.density-switch :deep(.ant-segmented-item-label) {
min-height: 24px;
padding: 0 8px;
font-size: 11px;
line-height: 24px;
}
.grid-empty {
display: flex;
min-height: 160px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: #94a3b8;
}
.grid-empty > svg {
font-size: 30px;
}
@media (max-width: 1100px) {
.page-scope-badge,
.grid-tool-button span:not(.ant-btn-icon) {
display: none;
}
.page-search {
width: 190px;
}
}
</style>
@@ -2,7 +2,6 @@
import type { DefaultOptionType } from 'ant-design-vue/es/select'; import type { DefaultOptionType } from 'ant-design-vue/es/select';
import type { import type {
CubeReportColumn,
CubeReportFile, CubeReportFile,
CubeReportMessage, CubeReportMessage,
CubeReportMetaStatus, CubeReportMetaStatus,
@@ -32,7 +31,6 @@ import {
Segmented, Segmented,
Select, Select,
Spin, Spin,
Table,
Tag, Tag,
Textarea, Textarea,
Tooltip, Tooltip,
@@ -41,7 +39,15 @@ import dayjs from 'dayjs';
import * as api from '#/api'; import * as api from '#/api';
import CubeDataGrid from './cube-data-grid.vue';
type WorkspaceView = 'chat' | 'data' | 'split'; type WorkspaceView = 'chat' | 'data' | 'split';
interface DataLoadError {
detail: string;
hint?: string;
message: string;
title: string;
}
const reports = ref<CubeSavedReport[]>([]); const reports = ref<CubeSavedReport[]>([]);
const reportPage = ref(1); const reportPage = ref(1);
@@ -78,6 +84,7 @@ const loadingSessions = ref(false);
const loadingMoreSessions = ref(false); const loadingMoreSessions = ref(false);
const loadingConversation = ref(false); const loadingConversation = ref(false);
const loadingData = ref(false); const loadingData = ref(false);
const dataLoadError = ref<DataLoadError | null>(null);
const exporting = ref(false); const exporting = ref(false);
const favoriting = ref(false); const favoriting = ref(false);
const refreshingCubeMetadata = ref(false); const refreshingCubeMetadata = ref(false);
@@ -92,11 +99,9 @@ const renameValue = ref('');
const renaming = ref(false); const renaming = ref(false);
const chatContainer = ref<HTMLElement | null>(null); const chatContainer = ref<HTMLElement | null>(null);
const panelContainer = ref<HTMLElement | null>(null); const panelContainer = ref<HTMLElement | null>(null);
const tableRegion = ref<HTMLElement | null>(null); const dataGridRef = ref<InstanceType<typeof CubeDataGrid> | null>(null);
const tableScrollY = ref(240);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const cubeMetadataStatus = ref<CubeReportMetaStatus | null>(null); const cubeMetadataStatus = ref<CubeReportMetaStatus | null>(null);
let tableResizeObserver: null | ResizeObserver = null;
let searchTimer: ReturnType<typeof setTimeout> | undefined; let searchTimer: ReturnType<typeof setTimeout> | undefined;
const selectedCompany = computed(() => const selectedCompany = computed(() =>
@@ -117,14 +122,8 @@ const hasData = computed(
Boolean(currentSession.value?.hasData) || Boolean(currentSession.value?.hasData) ||
dataPage.value.columns.length > 0, dataPage.value.columns.length > 0,
); );
const tableColumns = computed(() => const dataGridStorageKey = computed(
dataPage.value.columns.map((column: CubeReportColumn) => ({ () => currentReportId.value || currentSessionId.value || 'draft',
key: column.key,
dataIndex: column.key,
title: column.title,
ellipsis: true,
minWidth: 140,
})),
); );
const dataPanelStyle = computed(() => const dataPanelStyle = computed(() =>
activeView.value === 'split' activeView.value === 'split'
@@ -169,6 +168,7 @@ async function scrollToBottom() {
} }
function resetData() { function resetData() {
dataLoadError.value = null;
dataPage.value = { dataPage.value = {
columns: [], columns: [],
page: 1, page: 1,
@@ -178,6 +178,59 @@ function resetData() {
}; };
} }
function getErrorDetail(error: unknown) {
if (typeof error === 'string') return error;
if (error && typeof error === 'object') {
const payload = error as Record<string, unknown>;
if (typeof payload.detail === 'string') return payload.detail;
if (typeof payload.message === 'string') return payload.message;
}
return '数据查询失败,请稍后重试';
}
function extractCubeError(detail: string) {
const prefix = 'Cube SQL 生成失败:';
if (!detail.startsWith(prefix)) return detail;
const rawPayload = detail.slice(prefix.length).trim();
try {
const payload = JSON.parse(rawPayload) as Record<string, unknown>;
if (typeof payload.error === 'string') return payload.error;
} catch {
const match = rawPayload.match(/"error"\s*:\s*"((?:\\.|[^"\\])*)"/);
if (match?.[1]) {
try {
return JSON.parse(`"${match[1]}"`) as string;
} catch {
return match[1];
}
}
}
return detail;
}
function createDataLoadError(error: unknown): DataLoadError {
const detail = getErrorDetail(error);
const cubeError = extractCubeError(detail);
const missingField = cubeError.match(
/'([^']+)' not found for path '([^']+)'/,
);
if (detail.startsWith('Cube SQL 生成失败:')) {
return {
detail,
hint: missingField
? `Cube 模型中找不到字段 ${missingField[2]}(成员 ${missingField[1]})。字段可能已被删除、重命名或元数据尚未同步。`
: 'Cube 无法根据当前报表配置生成 SQL,请检查报表字段或刷新 Cube 元数据后重试。',
message: cubeError,
title: missingField ? 'Cube 字段不可用' : 'Cube SQL 生成失败',
};
}
return {
detail,
message: detail,
title: '数据加载失败',
};
}
async function loadScope() { async function loadScope() {
const scope = await api.getCubeReportScope(); const scope = await api.getCubeReportScope();
companies.value = scope.companies; companies.value = scope.companies;
@@ -309,23 +362,44 @@ async function loadReport(id: string) {
const report = await api.getCubeReport(id); const report = await api.getCubeReport(id);
currentReport.value = report; currentReport.value = report;
currentSessionId.value = report.conversationId; currentSessionId.value = report.conversationId;
try { currentSession.value = {
const detail = await api.getCubeReportSession(report.conversationId); createdAt: report.createdAt,
currentSession.value = detail.session; hasData: true,
messages.value = detail.messages; id: report.conversationId,
} catch { reportId: report.id,
currentSession.value = { tenantId: report.tenantId,
createdAt: report.createdAt, tenantName: report.tenantName,
hasData: true, title: report.title,
id: report.conversationId, updatedAt: report.updatedAt,
reportId: report.id, };
tenantId: report.tenantId, messages.value = [
tenantName: report.tenantName, ...(report.requirement
title: report.title, ? [
updatedAt: report.updatedAt, {
}; content: report.requirement,
messages.value = []; createdAt: report.createdAt,
} difyMessageId: null,
files: [],
id: `saved-report-${report.id}-user`,
role: 'user' as const,
status: 'completed' as const,
},
]
: []),
...(report.message
? [
{
content: report.message,
createdAt: report.updatedAt,
difyMessageId: null,
files: [],
id: `saved-report-${report.id}-assistant`,
role: 'assistant' as const,
status: 'completed' as const,
},
]
: []),
];
pendingFiles.value = []; pendingFiles.value = [];
resetData(); resetData();
activeView.value = 'split'; activeView.value = 'split';
@@ -496,11 +570,14 @@ async function stopResponse() {
async function loadData(page = dataPage.value.page) { async function loadData(page = dataPage.value.page) {
if (!currentSessionId.value) return; if (!currentSessionId.value) return;
dataLoadError.value = null;
loadingData.value = true; loadingData.value = true;
try { try {
dataPage.value = currentReportId.value dataPage.value = currentReportId.value
? await api.getCubeSavedReportData(currentReportId.value, { page }) ? await api.getCubeSavedReportData(currentReportId.value, { page })
: await api.getCubeReportData(currentSessionId.value, { page }); : await api.getCubeReportData(currentSessionId.value, { page });
} catch (error) {
dataLoadError.value = createDataLoadError(error);
} finally { } finally {
loadingData.value = false; loadingData.value = false;
} }
@@ -520,13 +597,32 @@ async function copyCurrentSql() {
} }
} }
async function copyDataError() {
if (!dataLoadError.value) return;
try {
await navigator.clipboard.writeText(dataLoadError.value.detail);
message.success('错误详情已复制');
} catch {
message.error('复制失败,请手动选择错误详情');
}
}
async function exportCurrentData() { async function exportCurrentData() {
if (!currentSessionId.value || exporting.value) return; if (!currentSessionId.value || exporting.value) return;
const exportColumns =
dataGridRef.value?.getExportColumns() ?? dataPage.value.columns;
const columnKeys = exportColumns.map((column) => column.key);
if (columnKeys.length === 0) {
message.warning('请至少保留一列后再导出');
return;
}
exporting.value = true; exporting.value = true;
try { try {
const result = currentReportId.value const result = currentReportId.value
? await api.exportCubeSavedReport(currentReportId.value) ? await api.exportCubeSavedReport(currentReportId.value, { columnKeys })
: await api.exportCubeReportSession(currentSessionId.value); : await api.exportCubeReportSession(currentSessionId.value, {
columnKeys,
});
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = result.url; anchor.href = result.url;
anchor.download = result.filename; anchor.download = result.filename;
@@ -534,7 +630,9 @@ async function exportCurrentData() {
document.body.append(anchor); document.body.append(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
message.success(`已生成 ${result.rowCount} 条数据`); message.success(
`已生成 ${result.rowCount} 条数据,共 ${columnKeys.length}`,
);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '数据导出失败'); message.error(error instanceof Error ? error.message : '数据导出失败');
} finally { } finally {
@@ -735,20 +833,6 @@ function stopResize() {
resizing.value = false; resizing.value = false;
} }
function observeTableRegion() {
tableResizeObserver?.disconnect();
if (!tableRegion.value) return;
tableResizeObserver = new ResizeObserver(([entry]) => {
if (!entry) return;
tableScrollY.value = Math.max(
120,
Math.floor(entry.contentRect.height - 48),
);
});
tableResizeObserver.observe(tableRegion.value);
}
watch(tableRegion, () => nextTick(observeTableRegion));
watch(sessionKeyword, () => { watch(sessionKeyword, () => {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => void reloadNavigation(), 320); searchTimer = setTimeout(() => void reloadNavigation(), 320);
@@ -763,13 +847,11 @@ onMounted(async () => {
loadAppParameters(), loadAppParameters(),
loadCubeMetadataStatus(), loadCubeMetadataStatus(),
]); ]);
await nextTick(observeTableRegion);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', stopResize); window.removeEventListener('pointerup', stopResize);
tableResizeObserver?.disconnect();
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
}); });
</script> </script>
@@ -1021,18 +1103,20 @@ onBeforeUnmount(() => {
刷新表头 刷新表头
</Button> </Button>
</Tooltip> </Tooltip>
<Button <Tooltip :title="`导出全部 ${dataPage.total} 条数据的当前可见列`">
class="toolbar-action" <Button
size="small" class="toolbar-action"
:loading="exporting" size="small"
:disabled="loadingData" :loading="exporting"
@click="exportCurrentData" :disabled="loadingData"
> @click="exportCurrentData"
<template #icon> >
<IconifyIcon icon="lucide:download" /> <template #icon>
</template> <IconifyIcon icon="lucide:download" />
导出表格 </template>
</Button> 导出表格
</Button>
</Tooltip>
<Button <Button
class="toolbar-action" class="toolbar-action"
size="small" size="small"
@@ -1049,56 +1133,84 @@ onBeforeUnmount(() => {
<Spin :spinning="loadingData" class="min-h-0 flex-1"> <Spin :spinning="loadingData" class="min-h-0 flex-1">
<div v-if="hasData" class="data-table-card"> <div v-if="hasData" class="data-table-card">
<details v-if="dataPage.sql" class="sql-disclosure"> <div v-if="dataLoadError" class="data-error-state">
<summary> <div class="data-error-icon">
<span class="sql-summary-title"> <IconifyIcon icon="lucide:database-zap" />
<IconifyIcon icon="lucide:terminal-square" /> </div>
执行 SQL <div class="data-error-content">
</span> <span class="data-error-eyebrow">数据查询未完成</span>
<Button <h2>{{ dataLoadError.title }}</h2>
size="small" <p class="data-error-message">{{ dataLoadError.message }}</p>
type="text" <p v-if="dataLoadError.hint" class="data-error-hint">
@click.stop.prevent="copyCurrentSql" <IconifyIcon icon="lucide:lightbulb" />
> <span>{{ dataLoadError.hint }}</span>
<template #icon> </p>
<IconifyIcon icon="lucide:copy" /> <div class="data-error-actions">
</template> <Button
复制 type="primary"
</Button> :loading="loadingData"
</summary> @click="loadData(dataPage.page)"
<pre>{{ dataPage.sql }}</pre> >
</details> <template #icon>
<div ref="tableRegion" class="data-table-region"> <IconifyIcon icon="lucide:refresh-cw" />
<Table </template>
:columns="tableColumns" 重新加载
:data-source="dataPage.rows" </Button>
:pagination="false" <Button @click="copyDataError">
:row-key=" <template #icon>
(record: Record<string, unknown>) => String(record.__rowKey) <IconifyIcon icon="lucide:copy" />
" </template>
:scroll="{ x: 'max-content', y: tableScrollY }" 复制错误详情
bordered </Button>
size="middle" </div>
> <details class="data-error-details">
<template #bodyCell="{ column, record }"> <summary>查看技术详情</summary>
<span class="cell-value">{{ <pre>{{ dataLoadError.detail }}</pre>
record[String(column.key ?? column.dataIndex ?? '')] ?? </details>
'—' </div>
}}</span>
</template>
</Table>
</div>
<div class="pagination-row">
<Pagination
:current="dataPage.page"
:page-size="dataPage.pageSize"
:show-size-changer="false"
:total="dataPage.total"
show-less-items
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div> </div>
<template v-else>
<details v-if="dataPage.sql" class="sql-disclosure">
<summary>
<span class="sql-summary-title">
<IconifyIcon icon="lucide:terminal-square" />
执行 SQL
</span>
<Button
size="small"
type="text"
@click.stop.prevent="copyCurrentSql"
>
<template #icon>
<IconifyIcon icon="lucide:copy" />
</template>
复制
</Button>
</summary>
<pre>{{ dataPage.sql }}</pre>
</details>
<div class="data-table-region">
<CubeDataGrid
:key="dataGridStorageKey"
ref="dataGridRef"
:columns="dataPage.columns"
:loading="loadingData"
:rows="dataPage.rows"
:storage-key="dataGridStorageKey"
/>
</div>
<div class="pagination-row">
<Pagination
:current="dataPage.page"
:page-size="dataPage.pageSize"
:show-size-changer="false"
:total="dataPage.total"
show-less-items
:show-total="(total: number) => `${total}`"
@change="handlePageChange"
/>
</div>
</template>
</div> </div>
<div v-else class="empty-workspace"> <div v-else class="empty-workspace">
<div class="empty-illustration"> <div class="empty-illustration">
@@ -1628,6 +1740,116 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
} }
.data-error-state {
display: flex;
min-height: 0;
overflow: auto;
flex: 1;
align-items: flex-start;
justify-content: center;
gap: 18px;
padding: clamp(34px, 7vh, 72px) clamp(20px, 5vw, 64px);
background:
radial-gradient(circle at 50% 0%, rgb(254 242 242 / 72%), transparent 45%),
linear-gradient(180deg, #fff, #fcfdff);
}
.data-error-icon {
display: grid;
width: 48px;
height: 48px;
flex: 0 0 auto;
place-items: center;
color: #dc2626;
font-size: 23px;
background: #fff1f2;
border: 1px solid #fecdd3;
border-radius: 14px;
box-shadow: 0 8px 22px rgb(190 18 60 / 9%);
}
.data-error-content {
width: min(680px, 100%);
min-width: 0;
}
.data-error-eyebrow {
display: block;
margin: 1px 0 5px;
color: #e11d48;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.08em;
}
.data-error-content h2 {
margin: 0;
color: #172033;
font-size: 20px;
font-weight: 680;
line-height: 1.4;
}
.data-error-message {
margin: 10px 0 0;
color: #475569;
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 12px;
line-height: 1.75;
overflow-wrap: anywhere;
}
.data-error-hint {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 16px 0 0;
padding: 11px 13px;
color: #854d0e;
font-size: 12px;
line-height: 1.65;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 9px;
}
.data-error-hint svg {
flex: 0 0 auto;
margin-top: 2px;
color: #d97706;
font-size: 15px;
}
.data-error-actions {
display: flex;
flex-wrap: wrap;
gap: 9px;
margin-top: 18px;
}
.data-error-actions :deep(.ant-btn) {
border-radius: 8px;
}
.data-error-details {
margin-top: 17px;
overflow: hidden;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 9px;
}
.data-error-details summary {
padding: 9px 12px;
color: #64748b;
font-size: 11px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.data-error-details pre {
max-height: 220px;
margin: 0;
overflow: auto;
padding: 12px;
color: #cbd5e1;
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 11px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
background: #0f172a;
border-top: 1px solid #1e293b;
}
.sql-disclosure { .sql-disclosure {
flex-shrink: 0; flex-shrink: 0;
overflow: hidden; overflow: hidden;
@@ -1675,13 +1897,6 @@ onBeforeUnmount(() => {
background: #0f172a; background: #0f172a;
border-top: 1px solid #1e293b; border-top: 1px solid #1e293b;
} }
.cell-value {
display: inline-block;
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination-row { .pagination-row {
display: flex; display: flex;
position: relative; position: relative;
@@ -2030,6 +2245,12 @@ onBeforeUnmount(() => {
} }
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.data-error-state {
align-items: stretch;
flex-direction: column;
justify-content: flex-start;
padding: 28px 20px;
}
.session-sidebar { .session-sidebar {
width: 220px; width: 220px;
} }
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,11 @@ type ExtendOptions<T = any> = {
* - data: 解构响应的BODY数据,只返回其中的data节点数据(会检查status和code是否为成功状态)。 * - data: 解构响应的BODY数据,只返回其中的data节点数据(会检查status和code是否为成功状态)。
*/ */
responseReturn?: 'body' | 'data' | 'raw'; responseReturn?: 'body' | 'data' | 'raw';
/**
* 是否由全局响应拦截器显示错误消息。
* 页面需要使用内联错误态时可设为 false。
*/
showErrorMessage?: boolean;
}; };
type RequestClientConfig<T = any> = AxiosRequestConfig<T> & ExtendOptions<T>; type RequestClientConfig<T = any> = AxiosRequestConfig<T> & ExtendOptions<T>;
+1 -1
View File
@@ -1,7 +1,7 @@
# push_docker.ps1 # push_docker.ps1
# Set version # Set version
$env:VERSION = "1.5.7" $env:VERSION = "1.5.8"
# Docker registry/repository # Docker registry/repository
$registry = "docker.bbitcn.net/bbit_ai/ce_vue" $registry = "docker.bbitcn.net/bbit_ai/ce_vue"