diff --git a/cjpm.lock b/cjpm.lock index 8954596..972ba8c 100644 --- a/cjpm.lock +++ b/cjpm.lock @@ -1,19 +1,19 @@ version = 0 [requires] + soulsoft_web_http = {version = "1.0.20260528"} soulsoft_extensions_options_configuration = {version = "1.0.20260528"} + soulsoft_web_routing = {version = "1.0.20260528"} + soulsoft_extensions_logging_configuration = {version = "1.0.20260528"} + soulsoft_extensions_configuration = {version = "1.0.20260528"} + soulsoft_serialization = {version = "1.0.20260528"} + soulsoft_extensions_injection = {version = "1.0.20260528"} + soulsoft_identity_claims = {version = "1.0.20260528"} soulsoft_extensions_options = {version = "1.0.20260528"} redis = {version = "1.0.20260627"} - soulsoft_serialization = {version = "1.0.20260528"} - soulsoft_identity_claims = {version = "1.0.20260528"} - soulsoft_web_cors = {version = "1.0.20260528"} - soulsoft_extensions_hosting = {version = "1.0.20260528"} - soulsoft_web_mvc = {version = "1.0.20260528"} - soulsoft_extensions_logging = {version = "1.0.20260528"} - soulsoft_extensions_logging_console = {version = "1.0.20260528"} - soulsoft_extensions_configuration = {version = "1.0.20260528"} - soulsoft_web_routing = {version = "1.0.20260528"} - soulsoft_extensions_injection = {version = "1.0.20260528"} soulsoft_web_hosting = {version = "1.0.20260528"} - soulsoft_extensions_logging_configuration = {version = "1.0.20260528"} - soulsoft_web_http = {version = "1.0.20260528"} + soulsoft_web_mvc = {version = "1.0.20260528"} + soulsoft_web_cors = {version = "1.0.20260528"} + soulsoft_extensions_logging_console = {version = "1.0.20260528"} + soulsoft_extensions_logging = {version = "1.0.20260528"} + soulsoft_extensions_hosting = {version = "1.0.20260528"} diff --git a/src/configurations/SimApiRequestLogOptions.cj b/src/configurations/SimApiRequestLogOptions.cj index bfebe3b..2772fa9 100644 --- a/src/configurations/SimApiRequestLogOptions.cj +++ b/src/configurations/SimApiRequestLogOptions.cj @@ -16,10 +16,20 @@ public class SimApiRequestLogOptions { public var showFullHeader: Bool = false /** - * 是否打印完整的响应体。 + * 是否打印完整的响应体(false 时截断到 200 字符,对齐 C#)。 */ public var showFullResponse: Bool = false + /** + * 请求地址是否显示完整 URL(false 时仅显示路径+查询串)。 + */ + public var showFullUrl: Bool = true + + /** + * 是否在请求行显示运行耗时(如 [POST] (12ms) http://...)。 + */ + public var showRunTime: Bool = true + /** * 请求字段显示最长长度(0 表示不截断)。 */ diff --git a/src/helpers/SimApiRequestDelegateFactory.cj b/src/helpers/SimApiRequestDelegateFactory.cj index aa14d13..f4cb520 100644 --- a/src/helpers/SimApiRequestDelegateFactory.cj +++ b/src/helpers/SimApiRequestDelegateFactory.cj @@ -16,6 +16,8 @@ package simapi.helpers import std.collection.* import std.io.* import std.reflect.* +import stdx.encoding.json.* +import stdx.serialization.serialization.* import soulsoft_web_http.* import soulsoft_web_mvc.annotations.* import soulsoft_web_mvc.core.* @@ -304,7 +306,12 @@ struct SimApiActionInvoker { if (let Some(status) <- details.status) { context.response.statusCode = UInt16(status) } - context.response.writeAsJson(details) + // 与 soulsoft writeAsJson 的 Serializable 分支一致:serialize().toJson().toString() + // (toJson 为 DataModel 接口扩展,需先上转);writeAsJson 内部会设置 contentType,此处补上 + context.response.contentType = "application/json; charset=utf-8" + let dm: DataModel = details.serialize() + let jsonText = dm.toJson().toString() + SimApiResponseWriter.write(context, jsonText) } } @@ -322,7 +329,7 @@ struct SimApiActionInvoker { if (let s: String <- actionResult) { // String 原样输出文本(对齐 C# string 返回直接写入) context.response.contentType = "application/json; charset=utf-8" - context.response.write(s) + SimApiResponseWriter.write(context, s) } else { ObjectResult(actionResult).invoke(context) } diff --git a/src/helpers/SimApiResponseFilter.cj b/src/helpers/SimApiResponseFilter.cj index 11e45a5..fa0c02c 100644 --- a/src/helpers/SimApiResponseFilter.cj +++ b/src/helpers/SimApiResponseFilter.cj @@ -6,6 +6,7 @@ package simapi.helpers import soulsoft_web_http.* +import simapi_serialization.* import simapi.communications.* /** @@ -24,7 +25,8 @@ public class SimApiResponseFilter { context => next(context) if (!context.response.hasStarted) { - context.response.writeAsJson(SimApiBaseResponse()) + context.response.contentType = "application/json; charset=utf-8" + SimApiResponseWriter.write(context, JsonSerializer.Serialize(SimApiBaseResponse())) } } } diff --git a/src/helpers/SimApiResponseWriter.cj b/src/helpers/SimApiResponseWriter.cj new file mode 100644 index 0000000..acbb0d6 --- /dev/null +++ b/src/helpers/SimApiResponseWriter.cj @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import soulsoft_web_http.* + +/** + * 响应写出工具:写出响应体并缓存文本,供请求日志中间件读取。 + * + * 背景:soulsoft 的 HttpResponse.body 为只写流(read 抛 UnsupportedException), + * 无法像 C# 那样用 MemoryStream 替换 Body 捕获响应内容; + * 故在统一写出入口缓存文本,请求日志中间件直接从 context.items 读取。 + */ +public class SimApiResponseWriter { + private init() {} + + /** + * 响应体缓存键(写入 HttpContext.items)。 + */ + public static let responseBodyCacheKey = "SimApi:ResponseBodyCache" + + /** + * 写出响应文本并缓存(供请求日志中间件读取)。 + * @param context HTTP 上下文。 + * @param text 响应正文文本。 + */ + public static func write(context: HttpContext, text: String): Unit { + context.items[responseBodyCacheKey] = text + context.response.write(text) + } +} diff --git a/src/helpers/SimApiResultWriter.cj b/src/helpers/SimApiResultWriter.cj index b627131..28e4342 100644 --- a/src/helpers/SimApiResultWriter.cj +++ b/src/helpers/SimApiResultWriter.cj @@ -42,9 +42,9 @@ public class SimApiResultWriter { } } - /// simapi_serialization 序列化后直接写响应体 + /// simapi_serialization 序列化后直接写响应体(经 SimApiResponseWriter 缓存,供请求日志读取) private static func writeJson(context: HttpContext, obj: Any): Unit { context.response.contentType = "application/json; charset=utf-8" - context.response.write(JsonSerializer.Serialize(obj)) + SimApiResponseWriter.write(context, JsonSerializer.Serialize(obj)) } } diff --git a/src/middlewares/SimApiExceptionMiddleware.cj b/src/middlewares/SimApiExceptionMiddleware.cj index 4731d58..6292eef 100644 --- a/src/middlewares/SimApiExceptionMiddleware.cj +++ b/src/middlewares/SimApiExceptionMiddleware.cj @@ -11,6 +11,7 @@ import simapi_serialization.* import simapi.communications.* import simapi.exceptions.* import simapi.configurations.* +import simapi.helpers.* /** * 异常处理中间件:全异常捕获,统一输出 HTTP 200 + JSON 响应。 @@ -47,7 +48,7 @@ public class SimApiExceptionMiddleware <: IMiddleware { if (!context.response.hasStarted) { context.response.statusCode = 200 context.response.contentType = "application/json; charset=utf-8" - context.response.write(responseJson(response)) + SimApiResponseWriter.write(context, responseJson(response)) } } } diff --git a/src/middlewares/SimApiRequestLogMiddleware.cj b/src/middlewares/SimApiRequestLogMiddleware.cj index db04da5..b4246ec 100644 --- a/src/middlewares/SimApiRequestLogMiddleware.cj +++ b/src/middlewares/SimApiRequestLogMiddleware.cj @@ -16,14 +16,16 @@ import simapi.configurations.* import simapi.helpers.* /** - * 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、耗时与异常。 + * 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、响应体、耗时与异常。 * 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。 * * 对齐说明: * - 请求体按 JSON 字段级截断(对齐 C#:仅对超长字符串字段截断,保留结构) * - 捕获下游异常并记录,随后重抛(对齐 C# ExceptionDispatchInfo + edi.Throw) - * - 响应体:soulsoft HttpResponse.body 只读不可替换(C# 用 MemoryStream 替换捕获), - * 此处以 Content-Length 作为替代信息;ShowFullResponse 选项因此暂不生效 + * - 响应体:soulsoft HttpResponse.body 只读且不可读回(read 抛 UnsupportedException), + * 无法像 C# 那样用 MemoryStream 替换捕获;改为在各统一写出入口 + * (SimApiResponseWriter)缓存响应文本,此处直接读取。ShowFullResponse=false 时截断到 200 字符(对齐 C#) + * - ShowFullUrl=false 时仅显示路径+查询串;ShowRunTime=true 时请求行显示 [POST] (xxxms) */ public class SimApiRequestLogMiddleware <: IMiddleware { private let _options: SimApiOptions @@ -39,13 +41,11 @@ public class SimApiRequestLogMiddleware <: IMiddleware { */ public func invoke(context: HttpContext, next: RequestDelegate): Unit { let start = MonoTime.now() - let fullUrl = context.request.getDisplayUrl() + let options = _options.simApiRequestLogOptions var sb = StringBuilder() - sb.append("[${context.request.method}] ${fullUrl}\n") - // 请求头 - if (_options.simApiRequestLogOptions.showFullHeader) { + if (options.showFullHeader) { sb.append("*( RequestHeaders [Full] ) =>\n") sb.append(serializeHeaders(context)) } else { @@ -67,20 +67,30 @@ public class SimApiRequestLogMiddleware <: IMiddleware { exception = Some(ex) } - // 响应信息 + // 请求行(耗时需在 next 之后计算,故最后拼装) let elapsed = MonoTime.now() - start let elapsedMs = elapsed / Duration.millisecond - sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms") - // 响应体捕获受 soulsoft 限制(body 只读不可替换),记录 Content-Length 作为替代 - if (let Some(len) <- context.response.contentLength) { - sb.append(" (响应体长度: ${len})") + 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") + + // 响应信息:状态码 + 响应体(经 SimApiResponseWriter 缓存读取) + sb.append("*( Response [${context.response.statusCode}] ) =>") + sb.append(readResponseBody(context)) sb.append("\n") if (let Some(ex) <- exception) { sb.append("Exception: ${ex.toString()}\n") } - _logger.info(sb.toString()) + _logger.info(sbHead.toString() + sb.toString()) // 重抛原异常(对齐 C# edi?.Throw()),由外层 ExceptionMiddleware 处理 if (let Some(ex) <- exception) { @@ -88,6 +98,27 @@ public class SimApiRequestLogMiddleware <: IMiddleware { } } + /// 构造完整请求 URL:stdx 服务端请求 URL 只有路径(无 scheme/host),需手动拼接。 + /// 对齐 C# 的 {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("{") @@ -134,6 +165,29 @@ public class SimApiRequestLogMiddleware <: IMiddleware { } } + /// 响应体读取:从 SimApiResponseWriter 缓存取;未捕获到时回退 Content-Length + private func readResponseBody(context: HttpContext): String { + let body = match (context.items.get(SimApiResponseWriter.responseBodyCacheKey)) { + case Some(v) => if (let s: String <- v) { s } else { "" } + case None => "" + } + if (body.isEmpty()) { + // 未捕获到响应体(如 IActionResult 直写路径):记录 Content-Length 作为替代 + if (let Some(len) <- context.response.contentLength) { + return " (响应体长度: ${len})" + } + return "" + } + if (_options.simApiRequestLogOptions.showFullResponse) { + return body + } + // 对齐 C#:ShowFullResponse=false 时截断到 200 字符 + if (body.size > 200) { + return body[0..200] + "...(${body.size})" + } + return body + } + /// 请求体截断:JSON 字段级截断(对齐 C#:仅对超长字符串字段截断),非 JSON 则整串截断 private func truncateBody(body: String): String { let maxLen = _options.simApiRequestLogOptions.requestStringLogLength