/* * Copyright (c) 2025 SimcuTeam. All rights reserved. * 遵循 MIT 许可证。 */ package simcu::simapi.middlewares import std.collection.* import std.io.* import std.time.* import soulsoft_web_http.* import soulsoft_extensions_logging.* import simcu::serialization.* import simcu::simapi.communications.* import simcu::simapi.configurations.* import simcu::simapi.helpers.* /** * 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、响应体、耗时与异常。 * * 对齐说明: * - 请求体按 JSON 字段级截断 * - 捕获下游异常并记录,随后重抛 * - 响应体:soulsoft HttpResponse.body 只读且不可读回(read 抛 UnsupportedException), * 无法像原版那样用 MemoryStream 替换捕获;改为在各统一写出入口 * (SimApiResponseWriter)缓存响应文本,此处直接读取。 * 响应行格式:*( Response [status] ) => [ N bytes ],随后换行输出响应体结构; * ShowFullResponse=false 时截断到 200 字符 * - ShowFullUrl=false 时仅显示路径+查询串;ShowRunTime=true 时请求行显示 [POST] (xxxms) */ public class SimApiRequestLogMiddleware <: IMiddleware { private let _options: SimApiOptions private let _logger: ILogger public init(options: SimApiOptions, loggerFactory: ILoggerFactory) { _options = options _logger = loggerFactory.createLogger("SimApi.RequestLog") } /** * 中间件入口(IMiddleware 风格)。 */ public func invoke(context: HttpContext, next: RequestDelegate): Unit { let start = MonoTime.now() let options = _options.simApiRequestLogOptions var sb = StringBuilder() // 请求头 if (options.showFullHeader) { sb.append("*( RequestHeaders [Full] ) =>\n") sb.append(serializeHeaders(context)) } else { sb.append("*( RequestHeaders ) =>\n") let token = context.request.headers.get("Token") ?? "" let queryId = context.request.headers.get("Query-Id") ?? "" sb.append("Token: ${token} QueryId: ${queryId}\n") } // 请求体 sb.append("*( RequestBody ) =>\n") sb.append(readRequestBody(context)) // 调用下一级,捕获异常以便记录并重抛 var exception: ?Exception = None try { next(context) } catch (ex: Exception) { exception = Some(ex) } // 请求行(耗时需在 next 之后计算,故最后拼装) let elapsed = MonoTime.now() - start let elapsedMs = elapsed / Duration.millisecond let url = if (options.showFullUrl) { buildDisplayUrl(context) } else { context.request.path.toString() + context.request.queryString.toString() } var sbHead = StringBuilder() sbHead.append("[${context.request.method}]") if (options.showRunTime) { sbHead.append(" (${elapsedMs}ms)") } sbHead.append(" ${url}\n") // 响应信息:状态码 + 响应体长度(bytes),响应体结构换行显示 let responseBody = readResponseBody(context) let bodyLen = if (responseBody.isEmpty()) { context.response.contentLength ?? 0 } else { responseBody.size } sb.append("*( Response [${context.response.statusCode}] ) => [ ${bodyLen} bytes ]\n") if (!responseBody.isEmpty()) { let display = if (_options.simApiRequestLogOptions.showFullResponse) { responseBody } else if (responseBody.size > 200) { // ShowFullResponse=false 时截断到 200 字符(长度仍显示完整字节数) responseBody[0..200] + "...(${responseBody.size})" } else { responseBody } // 去掉响应体末尾换行:结构体最后不多加换行(logger 收尾会补一个换行) var text = display while (text.endsWith("\n")) { text = text[0..text.size - 1] } sb.append(text) } if (let Some(ex) <- exception) { sb.append("\nException: ${ex.toString()}\n") } _logger.info(sbHead.toString() + sb.toString()) // 重抛原异常,由外层 ExceptionMiddleware 处理 if (let Some(ex) <- exception) { throw ex } } /// 构造完整请求 URL:stdx 服务端请求 URL 只有路径(无 scheme/host),需手动拼接。 /// {Scheme}://{Host}{Path}{QueryString};Host 优先取 Host 请求头。 private func buildDisplayUrl(context: HttpContext): String { var sb = StringBuilder() let scheme = context.request.scheme sb.append(if (scheme.isEmpty()) { "http" } else { scheme }) sb.append("://") let hostHeader = context.request.headers.get("Host") ?? "" if (!hostHeader.isEmpty()) { sb.append(hostHeader) } else { let host = context.request.host.toString() if (!host.isEmpty()) { sb.append(host) } } sb.append(context.request.path.toString()) sb.append(context.request.queryString.toString()) sb.toString() } private func serializeHeaders(context: HttpContext): String { var sb = StringBuilder() sb.append("{") var first = true for ((name, values) in context.request.headers) { if (!first) { sb.append(",") } sb.append("\"${SimApiUtil.escapeJson(name)}\":\"${SimApiUtil.escapeJson(joinValues(values))}\"") first = false } sb.append("}\n") sb.toString() } private func joinValues(values: Collection): String { var sb = StringBuilder() var first = true for (v in values) { if (!first) { sb.append(",") } sb.append(v) first = false } sb.toString() } private func readRequestBody(context: HttpContext): String { try { context.request.enableBuffering() var buffer = Array(4096, repeat: 0) var sb = StringBuilder() var read = context.request.body.read(buffer) while (read > 0) { sb.appendFromUtf8(buffer.slice(0, read)) read = context.request.body.read(buffer) } let bodyText = sb.toString() // 重置流位置,供后续业务读取;同时缓存 body(流可能不可重读) if (let seekable: Seekable <- context.request.body) { seekable.seek(SeekPosition.Begin(0)) } context.items["SimApi:BodyCache"] = bodyText return truncateBody(bodyText) } catch (_: Exception) { return "(读取请求体失败)\n" } } /// 响应体读取:从 SimApiResponseWriter 缓存取完整响应体;未捕获到时返回空串 /// (长度与截断由调用方处理) private func readResponseBody(context: HttpContext): String { match (context.items.get(SimApiResponseWriter.responseBodyCacheKey)) { case Some(v) => if (let s: String <- v) { s } else { "" } case None => "" } } /// 请求体截断:JSON 字段级截断,非 JSON 则整串截断 private func truncateBody(body: String): String { let maxLen = _options.simApiRequestLogOptions.requestStringLogLength if (maxLen <= 0) { return body + "\n" } try { // 用 simapi_serialization 解析为动态结构,仅截断超长字符串字段(保持结构) let map = JsonSerializer.deserialize>(body) var sb = StringBuilder() sb.append("{") var first = true for ((k, v) in map) { if (!first) { sb.append(",") } sb.append("\"${SimApiUtil.escapeJson(k)}\":") if (let s: String <- v) { if (s.size > maxLen) { sb.append("\"${SimApiUtil.escapeJson(s[0..maxLen])}...(${s.size})\"") } else { sb.append("\"${SimApiUtil.escapeJson(s)}\"") } } else { sb.append(SimApiUtil.json(Some(v))) } first = false } sb.append("}") return sb.toString() + "\n" } catch (_: Exception) { } // 非 JSON 或解析失败:整串按长度截断 if (body.size <= maxLen) { return body + "\n" } return body[0..maxLen] + "...(${body.size})\n" } }