From 932fce1b9e4838f652d490fd970e6e0c6e574aa6 Mon Sep 17 00:00:00 2001 From: xRain Date: Sun, 16 Aug 2026 12:46:15 +0800 Subject: [PATCH] first version --- .gitignore | 1 + README.md | 326 +++++++++++++ cjpm.lock | 20 + cjpm.toml | 40 ++ src/communications/SimApiBaseRequest.cj | 53 +++ src/communications/SimApiBaseResponse.cj | 160 +++++++ src/communications/SimApiDataResponse.cj | 88 ++++ src/communications/SimApiJson.cj | 100 ++++ src/communications/SimApiLoginItem.cj | 167 +++++++ src/configurations/SimApiAuthCenterOptions.cj | 40 ++ src/configurations/SimApiDocOptions.cj | 89 ++++ src/configurations/SimApiExceptionOptions.cj | 21 + src/configurations/SimApiHttpClientOptions.cj | 23 + src/configurations/SimApiJobOptions.cj | 65 +++ src/configurations/SimApiOptions.cj | 190 ++++++++ src/configurations/SimApiRequestLogOptions.cj | 29 ++ src/configurations/SimApiRouteOptions.cj | 29 ++ src/configurations/SimApiStorageOptions.cj | 20 + src/configurations/SimApiSynapseOptions.cj | 51 ++ src/controllers/SimApiAuthController.cj | 35 ++ src/controllers/SimApiBaseController.cj | 79 ++++ src/controllers/SimApiCommonController.cj | 91 ++++ src/exceptions/SimApiException.cj | 18 + src/extensions/SimApiExtensions.cj | 315 +++++++++++++ .../SimApiRequestDelegateFactory.cj | 145 ++++++ src/helpers/SimApiAesUtil.cj | 442 ++++++++++++++++++ src/helpers/SimApiAuth.cj | 222 +++++++++ src/helpers/SimApiCache.cj | 93 ++++ src/helpers/SimApiControllerScanner.cj | 104 +++++ src/helpers/SimApiError.cj | 64 +++ src/helpers/SimApiHttpClient.cj | 169 +++++++ src/helpers/SimApiResponseFilter.cj | 32 ++ src/helpers/SimApiUtil.cj | 156 +++++++ src/interfaces/ISimApiAuthChecker.cj | 21 + src/logger/SimApiLogger.cj | 145 ++++++ src/macros/ReadTomlVersionMacro.cj | 95 ++++ src/middlewares/SimApiAuthMiddleware.cj | 41 ++ src/middlewares/SimApiExceptionMiddleware.cj | 74 +++ src/middlewares/SimApiRequestLogMiddleware.cj | 123 +++++ src/simapi.cj | 29 ++ 40 files changed, 4005 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cjpm.lock create mode 100644 cjpm.toml create mode 100644 src/communications/SimApiBaseRequest.cj create mode 100644 src/communications/SimApiBaseResponse.cj create mode 100644 src/communications/SimApiDataResponse.cj create mode 100644 src/communications/SimApiJson.cj create mode 100644 src/communications/SimApiLoginItem.cj create mode 100644 src/configurations/SimApiAuthCenterOptions.cj create mode 100644 src/configurations/SimApiDocOptions.cj create mode 100644 src/configurations/SimApiExceptionOptions.cj create mode 100644 src/configurations/SimApiHttpClientOptions.cj create mode 100644 src/configurations/SimApiJobOptions.cj create mode 100644 src/configurations/SimApiOptions.cj create mode 100644 src/configurations/SimApiRequestLogOptions.cj create mode 100644 src/configurations/SimApiRouteOptions.cj create mode 100644 src/configurations/SimApiStorageOptions.cj create mode 100644 src/configurations/SimApiSynapseOptions.cj create mode 100644 src/controllers/SimApiAuthController.cj create mode 100644 src/controllers/SimApiBaseController.cj create mode 100644 src/controllers/SimApiCommonController.cj create mode 100644 src/exceptions/SimApiException.cj create mode 100644 src/extensions/SimApiExtensions.cj create mode 100644 src/extensions/SimApiRequestDelegateFactory.cj create mode 100644 src/helpers/SimApiAesUtil.cj create mode 100644 src/helpers/SimApiAuth.cj create mode 100644 src/helpers/SimApiCache.cj create mode 100644 src/helpers/SimApiControllerScanner.cj create mode 100644 src/helpers/SimApiError.cj create mode 100644 src/helpers/SimApiHttpClient.cj create mode 100644 src/helpers/SimApiResponseFilter.cj create mode 100644 src/helpers/SimApiUtil.cj create mode 100644 src/interfaces/ISimApiAuthChecker.cj create mode 100644 src/logger/SimApiLogger.cj create mode 100644 src/macros/ReadTomlVersionMacro.cj create mode 100644 src/middlewares/SimApiAuthMiddleware.cj create mode 100644 src/middlewares/SimApiExceptionMiddleware.cj create mode 100644 src/middlewares/SimApiRequestLogMiddleware.cj create mode 100644 src/simapi.cj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f97022 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..27c16c3 --- /dev/null +++ b/README.md @@ -0,0 +1,326 @@ +# SimApi for Cangjie(simapi) + +> 仓颉版 SimApi:ASP.NET Core 风格 API 基础框架,移植自 C# 项目 [SimApi](https://github.com/SimcuTeam/simapi-net)(`E:\simcu\simapi-net`)。 + +提供**统一响应格式、异常拦截、Token 认证、缓存、工具集、HTTP 客户端**等 API 基础能力。 + +--- + +## 快速开始 + +```cangjie +package your_app + +import soulsoft_web_http.* +import soulsoft_web_routing.* +import soulsoft_web_hosting.* +import soulsoft_extensions_logging.* +import soulsoft_extensions_injection.* +import simapi.extensions.* +import simapi.communications.* + +main(args: Array) { + let builder = WebHost.createBuilder(args) + builder.services.addRouting() + builder.services.addLogging() + + // 注册 SimApi 服务(与 addLogging 同样式) + builder.addSimApi { options => + options.enableSimApiAuth = true // Token 认证(未配 Redis 自动用 InMemory) + options.enableSimApiCache = true // 缓存 + options.enableSimApiException = true // 全局异常拦截 + } + + let host = builder.build() + host.useSimApi() + + // 业务接口:返回统一响应格式 + host.mapGet("hello") { + context => + context.response.write(SimApiBaseResponse().toJsonString(dataJson: "\"hello cangjie.\"")) + } + + host.run() +} +``` + +### 统一响应格式 + +所有接口输出 JSON,HTTP 状态码始终 `200`,错误信息在 `code` 字段: + +| code | 含义 | +| ---- | ---------- | +| 200 | 成功 | +| 204 | 无数据 | +| 400 | 参数错误 | +| 401 | 需要登录 | +| 403 | 无权访问 | +| 404 | 资源不存在 | +| 500 | 服务器错误 | + +### 异常处理流程 + +``` +请求 → SimApiExceptionMiddleware(全异常捕获→HTTP 200+JSON) + → SimApiAuthMiddleware(Token→LoginInfo) + → 路由 → 业务处理 +``` + +--- + +## 项目结构 + +``` +simapi-cj/ +├── cjpm.toml # 包配置 +├── src/ +│ ├── communications/ # SimApiBaseResponse, PageResponse, SimApiLoginItem, ApiResult, 请求 DTO +│ ├── configurations/ # SimApiOptions + 各模块 Option(含 ConfigureSimApiXxx 回调) +│ ├── controllers/ # SimApiBaseController, SimApiCommonController, SimApiAuthController(MVC 写法) +│ ├── exceptions/ # SimApiException +│ ├── extensions/ # SimApiExtensions(addSimApi / useSimApi + 内置路由) +│ ├── helpers/ # SimApiError, SimApiUtil, SimApiAuth, SimApiCache, SimApiHttpClient +│ ├── interfaces/ # ISimApiAuthChecker +│ ├── logger/ # SimApiLogger, SimApiLoggerProvider(彩色日志) +│ └── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware +``` + +--- + +## 模块说明 + +### 1. 错误处理 — SimApiError + +```cangjie +import simapi.helpers.* + +SimApiError.error(500, "服务器内部错误") // 直接抛错 +SimApiError.errorWhen(amount <= 0, 400, "金额无效") // 条件为 true 时抛错 +SimApiError.errorWhenFalse(hasPermission, 403, "无权操作") +SimApiError.errorWhenNone(someOptional, 404, "用户不存在") +``` + +### 2. 认证 — SimApiAuth + +```cangjie +import simapi.helpers.* +import simapi.communications.* + +let auth = SimApiAuth(redisConfiguration: "") // 配 Redis 用 Redis,否则 InMemory + +let token = auth.login(SimApiLoginItem(id: "user-001")) // 默认 7 天 +let login = auth.getLogin(token) // 获取登录信息 +auth.logout(token) // 退出登录 +auth.logoutAll("user-001") // 退出全部 +``` + +- **Redis 模式**:配置 `RedisConfiguration`(如 `"localhost:6379"`)时使用,支持多实例共享 +- **InMemory 模式**:零配置,适合开发/测试;重启后登录态丢失 +- **Token 传参**:Header `Token: ` 或 Query `token=` + +### 3. 缓存 — SimApiCache + +```cangjie +let cache = SimApiCache(redisConfiguration: "") +cache.set("key", "value") +let v = cache.get("key") // ?String +cache.hasKey("key") // Bool +cache.remove("key") +``` + +Key 自动加前缀 `SimApi:Cache:`。 + +### 4. 工具集 — SimApiUtil + +```cangjie +SimApiUtil.cstNow // UTC+8 时间 +SimApiUtil.timestampNow // 秒级时间戳 +SimApiUtil.md5("text") // 32 位十六进制 +SimApiUtil.sha1("text") // 40 位 +SimApiUtil.base64Encode("text") / base64Decode("...") +SimApiUtil.checkCell("13800138000") // 手机号 +SimApiUtil.checkEmail("a@b.com") // 邮箱 +``` + +### 5. HTTP 客户端 — SimApiHttpClient + +用于调用其他带签名/AES 的 SimApi 服务: + +```cangjie +let client = SimApiHttpClient(options: SimApiHttpClientOptions()) // 配置 server/appId/appKey + +// 返回泛型 T(对齐 .NET SignQuery/AesQuery/AesSignQuery),T 需实现 ISerialization +let resp1 = client.signQuery("/api/hello", body: "{\"a\":1}") +let resp2 = client.aesQuery("/api/data", body: "{\"a\":1}") +let resp3 = client.aesSignQuery("/api/data", body: "{\"a\":1}") +``` + +### 5.1 请求日志 — enableRequestLog + +记录每次请求的方法、URL、请求头、请求体、响应状态码与耗时: + +```cangjie +builder.addSimApi { options => + options.enableRequestLog = true + options.simApiRequestLogOptions.showFullHeader = true // 打印完整 Header(默认只打 Token/Query-Id) + options.simApiRequestLogOptions.requestStringLogLength = 200 // 请求体截断长度(0 不截断) +} +``` + +输出示例: + +``` +[GET] /hello +*( RequestHeaders [Full] ) => +{"host":"127.0.0.1:5000",...} +*( RequestBody ) => + +*( Response [200] ) => 1.756400ms +``` + +### 5.2 日志格式 — SimApiLogger + +`enableLogger`(默认 `true`)时自动使用 `SimApiLoggerProvider`(替换 soulsoft 默认控制台格式),输出格式对齐 C# 原版: + +``` +[ 分类 ][ 时间:毫秒 ][ 级别 ] +消息内容 +``` + +按级别着色: + +| 级别 | 颜色 | +|------|------| +| Debug | 深紫(DarkMagenta) | +| Info | 深青(DarkCyan) | +| Warn | 黄(Yellow) | +| Error | 红(Red) | +| Fatal | 深红(DarkRed 粗体近似) | +| 其他 | 白(White) | + +### 6. 内置路由(UseSimApi 自动注册) + +| 路由 | 方法 | 条件 | 说明 | +| ----------------- | -------- | ---------------------------- | -------------------------- | +| `/versions` | GET/POST | 始终 | 返回 SimApi/App 版本 | +| `/user/info` | POST | `enableSimApiAuth` | 需登录,返回 LoginInfo | +| `/auth/logout` | POST | `enableSimApiAuth` | 退出登录 | +| `/exception/{code}` | GET | 始终 | 错误反馈 | + +### 7. 认证后处理 Hook — ISimApiAuthChecker + +```cangjie +import simapi.interfaces.* + +class MyAuthChecker <: ISimApiAuthChecker { + public func run(loginItem: SimApiLoginItem, token: String): Unit { + // 认证成功后执行 + } +} +``` + +--- + +## SimApiOptions 完整配置 + +```cangjie +builder.addSimApi { options => + options.redisConfiguration = "localhost:6379" // Redis(可选) + + // 功能开关 + options.enableSimApiAuth = false // Token 认证 + options.enableSimApiCache = true // 缓存 + options.enableSimApiException = true // 全局异常拦截 + options.enableSimApiResponseFilter = true // 响应统一封装 + options.enableSimApiHttpClient = false // HTTP 客户端 + options.enableRequestLog = false // 请求日志中间件 + options.enableCors = true // 全量 CORS + options.enableLogger = true // 控制台日志 + + // .NET 风格子模块配置回调(对齐 C# ConfigureSimApiXxx) + options.configureSimApiRoute { route => + route.userInfoRoute = Some("user/info") + route.logoutRoute = Some("auth/logout") + } + options.configureSimApiRequestLog { opt => + opt.showFullResponse = true + opt.showFullHeader = false + opt.requestStringLogLength = 50 + } + options.configureSimApiHttpClient { http => + http.appId = "your-app-id" + http.appKey = "your-app-key" + http.server = "https://api.example.com" + } +} +``` + +## 内置控制器(MVC 写法) + +simapi 提供 Spire MVC 控制器(继承 `SimApiBaseController`),宿主通过 `addControllers` + 手动 `AssemblyPart` 注册(当前 cjc 无法自动扫描包子包): + +| 控制器 | 路由 | 说明 | +|--------|------|------| +| `SimApiCommonController` | `/exception/{code}`、`/webconfig`、`/user/info` | 通用内置路由 | +| `SimApiAuthController` | `/auth/logout` | 退出登录 | +| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` | + +```cangjie +import simapi.controllers.* + +// 控制器写法:继承 SimApiBaseController,注解路由 + DI 注入 +public class MyController <: SimApiBaseController { + private let _auth: SimApiAuth + public init(auth: SimApiAuth) { this._auth = auth } + + @HttpPost["my/route"] + public func myAction(@FromBody request: MyRequest): ApiResult { + requireLogin() + ApiResult.ok() + } +} + +// 宿主注册 +let mvc = builder.services.addControllers() +mvc.addApplicationPart(AssemblyPart("simapi.controllers", [ + TypeInfo.of(), + TypeInfo.of(), +])) +``` + +--- + +## 未实现模块(选项占位) + +以下 C# 原包功能因仓颉生态暂无对应库,**选项保留但未实现**: + +| 选项 | 原功能 | 状态 | +|------|--------|------| +| `enableSimApiDoc` | Swagger 文档 | ❌ 未实现 | +| `enableSimApiStorage` | S3/MinIO 存储 | ❌ 未实现 | +| `enableSynapse` | MQTT 通信 | ❌ 未实现 | +| `enableJob` | Hangfire 任务调度 | ❌ 未实现 | +| `enableSimApiAuthGate` | Auth Center 网关鉴权 | ❌ 未实现 | +| `SimApiAesUtil` | AES-256-CBC | ⚠️ 仓颉 std 无 AES,暂用 Base64 占位 | + +--- + +## 依赖 + +| 依赖 | 用途 | +|------|------| +| `soulsoft_web_http / routing / hosting` | Web 框架(ASP.NET Core 仓颉移植版) | +| `soulsoft_extensions_logging` 系列 | 日志 | +| `soulsoft_extensions_injection` | 依赖注入 | +| `soulsoft_extensions_configuration` | 配置 | +| `soulsoft_serialization` | JSON 序列化 | +| `redis`(pkg.cangjie-lang.cn) | Redis 客户端(认证/缓存 Redis 模式) | +| `stdx`(CANGJIE_STDX_PATH) | 标准扩展库(md5/sha1/base64/http) | + +> 构建前需设置 `CANGJIE_STDX_PATH` 指向本地 stdx 的 `static/stdx` 目录。 + +--- + +## 许可证 + +MIT diff --git a/cjpm.lock b/cjpm.lock new file mode 100644 index 0000000..ac90770 --- /dev/null +++ b/cjpm.lock @@ -0,0 +1,20 @@ +version = 0 + +[requires] + soulsoft_extensions_hosting = {version = "1.0.20260528"} + soulsoft_web_http = {version = "1.0.20260528"} + soulsoft_web_routing = {version = "1.0.20260528"} + soulsoft_web_cors = {version = "1.0.20260528"} + soulsoft_web_hosting = {version = "1.0.20260528"} + soulsoft_extensions_logging = {version = "1.0.20260528"} + soulsoft_web_mvc = {version = "1.0.20260528"} + soulsoft_extensions_logging_console = {version = "1.0.20260528"} + soulsoft_extensions_options = {version = "1.0.20260528"} + soulsoft_extensions_logging_configuration = {version = "1.0.20260528"} + soulsoft_serialization = {version = "1.0.20260528"} + soulsoft_extensions_configuration = {version = "1.0.20260528"} + soulsoft_extensions_options_configuration = {version = "1.0.20260528"} + soulsoft_extensions_injection = {version = "1.0.20260528"} + soulsoft_net_http = {version = "1.0.20260528"} + redis = {version = "1.0.20260627"} + soulsoft_identity_claims = {version = "1.0.20260528"} diff --git a/cjpm.toml b/cjpm.toml new file mode 100644 index 0000000..5c36e65 --- /dev/null +++ b/cjpm.toml @@ -0,0 +1,40 @@ +[package] + cjc-version = "1.1.3" + name = "simapi" + description = "SimApi 仓颉版:ASP.NET Core 风格 API 基础框架(统一响应/异常拦截/Token认证/缓存/工具集/HTTP客户端)" + version = "5.2.12" + target-dir = "" + output-type = "static" + override-compile-option = "" + link-option = "" + package-configuration = {} + +[dependencies] + soulsoft_web_http = "1.0.20260528" + soulsoft_web_routing = "1.0.20260528" + soulsoft_web_hosting = "1.0.20260528" + soulsoft_web_mvc = "1.0.20260528" + soulsoft_web_cors = "1.0.20260528" + soulsoft_extensions_logging = "1.0.20260528" + soulsoft_extensions_logging_console = "1.0.20260528" + soulsoft_extensions_logging_configuration = "1.0.20260528" + soulsoft_extensions_configuration = "1.0.20260528" + soulsoft_extensions_injection = "1.0.20260528" + soulsoft_extensions_options = "1.0.20260528" + soulsoft_serialization = "1.0.20260528" + soulsoft_net_http = "1.0.20260528" + redis = "1.0.20260627" + +[target] + [target.x86_64-w64-mingw32] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated -lcrypt32" + [target.x86_64-w64-mingw32.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] + [target.x86_64-unknown-linux-gnu] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated -ldl" + [target.x86_64-unknown-linux-gnu.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] + [target.aarch64-unknown-linux-gnu] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated -ldl" + [target.aarch64-unknown-linux-gnu.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] diff --git a/src/communications/SimApiBaseRequest.cj b/src/communications/SimApiBaseRequest.cj new file mode 100644 index 0000000..08f9d5a --- /dev/null +++ b/src/communications/SimApiBaseRequest.cj @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.communications + +/** + * 基础请求 DTO。 + */ +public class SimApiBaseRequest {} + +/** + * 仅包含 Id 的请求。 + */ +public class SimApiStringIdOnlyRequest { + public var id: String = "" + + public init() {} + + public init(id: String) { + this.id = id + } +} + +/** + * 单字段请求。 + * @param T 数据类型。 + */ +public class SimApiOneFieldRequest { + public var data: ?T = None + + public init() {} + + public init(data: T) { + this.data = Some(data) + } +} + +/** + * 基础分页请求。 + */ +public class SimApiBasePageRequest { + public var page: Int64 = 1 + public var count: Int64 = 20 + + public init() {} + + public init(page: Int64, count: Int64) { + this.page = page + this.count = count + } +} diff --git a/src/communications/SimApiBaseResponse.cj b/src/communications/SimApiBaseResponse.cj new file mode 100644 index 0000000..e07ee9c --- /dev/null +++ b/src/communications/SimApiBaseResponse.cj @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.communications + +import std.collection.* +import stdx.encoding.json.* +import soulsoft_serialization.* +import soulsoft_serialization.macros.* + +/** + * 基础响应体:所有接口统一返回该结构。 + * HTTP 状态码始终 200,业务错误通过 code 字段表达。 + */ +@Serialization +public open class SimApiBaseResponse { + public var _code: Int64 = 200 + public var _message: String = "成功" + + public init(code: Int64, message: String) { + this._code = code + this._message = message + } + + public init(code: Int64) { + this._code = code + this._message = getDefaultMessage(code) + } + + public init() { + this._code = 200 + this._message = "成功" + } + + /** + * 默认错误代码对应提示信息。 + */ + public static func getDefaultMessage(code: Int64): String { + match (code) { + case 200 => "成功" + case 204 => "没有数据" + case 400 => "参数错误" + case 401 => "需要登录" + case 403 => "无权访问" + case 404 => "请求资源不存在" + case 500 => "服务器错误" + case _ => "未知错误代码" + } + } + + /** + * 序列化为 JSON 字符串(可选 data 字段)。 + * @param dataJson 已序列化的 data JSON 字符串(可选)。 + */ + public func toJsonString(dataJson!: String = ""): String { + var sb = StringBuilder() + sb.append("{\"code\":${_code},\"message\":\"${SimApiJson.escapeJson(_message)}\"") + if (!dataJson.isEmpty()) { + sb.append(",\"data\":${dataJson}") + } + sb.append("}") + sb.toString() + } +} + +/** + * 分页内容返回。 + * @param T 列表元素类型。 + * 说明:泛型类暂不通过 @Serialization 宏序列化,可手动转换为 SimApiResponse。 + */ +public class PageResponse { + public var _list: Array = Array() + public var _page: Int64 = 1 + public var _count: Int64 = 20 + public var _total: Int64 = 0 + + public init() {} + + public init(list: Array, page: Int64, count: Int64, total: Int64) { + this._list = list + this._page = page + this._count = count + this._total = total + } +} + +/** + * 带数据的动态响应。 + * @param T 数据类型。 + * 说明:泛型类手写实现 ISerialization>(serialize + deserialize), + * data 内嵌为对象(对齐 C# SimApiBaseResponse.Data 是 T? 而非字符串)。 + * 序列化支持动态结构(HashMap 等经 SimApiJson 内嵌);反序列化要求 T <: ISerialization。 + */ +public class SimApiResponse <: SimApiBaseResponse & ISerialization> where T <: ISerialization { + public var _data: ?T = None + + public init() { + super() + } + + public init(data: T) { + super() + this._data = Some(data) + } + + public init(code: Int64, message: String) { + super(code, message) + } + + public init(code: Int64, message: String, data: T) { + super(code, message) + this._data = Some(data) + } + + /** + * 序列化为 DataModel:code + message + data(data 为对象)。 + */ + public override func serializeObject(options: JsonSerializerOptions): DataModel { + let dms = DataModelStruct() + dms.add(Field("code", DataModelInt(_code))) + dms.add(Field("message", DataModelString(_message))) + if (let Some(data) <- _data) { + if (let ser: ISerializable <- data) { + dms.add(Field("data", ser.serializeObject(options))) + } else { + // 动态结构(HashMap 等):经 SimApiJson 序列化后解析内嵌为对象 + let json = SimApiJson.json(Some(data)) + dms.add(Field("data", DataModel.fromJson(JsonValue.fromStr(json)))) + } + } + dms + } + + /** + * 从 DataModel 反序列化:code + message + data(对齐 .NET JsonSerializer.Deserialize)。 + */ + public static func deserializeObject(dm: DataModel, options: JsonSerializerOptions): SimApiResponse { + let resp = SimApiResponse() + if (let dms: DataModelStruct <- dm) { + for (field in dms.getFields()) { + match (field.getName()) { + case "code" => + if (let v: DataModelInt <- field.getData()) { + resp._code = v.getValue() + } + case "message" => + if (let v: DataModelString <- field.getData()) { + resp._message = v.getValue() + } + case "data" => + resp._data = Some(T.deserializeObject(field.getData(), options)) + case _ => () + } + } + } + resp + } +} diff --git a/src/communications/SimApiDataResponse.cj b/src/communications/SimApiDataResponse.cj new file mode 100644 index 0000000..67acc85 --- /dev/null +++ b/src/communications/SimApiDataResponse.cj @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.communications + +import std.collection.* +import stdx.encoding.json.* +import soulsoft_serialization.* + +/** + * 带任意对象数据的响应(非泛型版,供响应自动封装使用)。 + * + * 与 SimApiResponse 的区别:data 为 Any(运行时类型不定),用于 + * SimApiRequestDelegateFactory 派发结果时统一包装 DTO/数组/动态结构。 + * + * 序列化规则(对齐 SimApiResponse): + * - data 实现了 ISerializable(@Serialization DTO、Array 等)→ data 内嵌为对象 + * - data 为动态结构(如 HashMap,不满足泛型 ISerialization 约束) + * → 经 SimApiJson 序列化后解析内嵌为对象(而非字符串) + * 因此 data 在 JSON 中始终是对象/数组/标量,不会是"JSON 字符串"。 + */ +public class SimApiDataResponse <: SimApiBaseResponse & ISerialization { + public var _data: ?Any = None + + public init() { + super() + } + + public init(data: Any) { + super() + this._data = Some(data) + } + + public init(code: Int64, message: String) { + super(code, message) + } + + public init(code: Int64, message: String, data: Any) { + super(code, message) + this._data = Some(data) + } + + /** + * 序列化为 DataModel:code + message + data(data 为对象)。 + */ + public override func serializeObject(options: JsonSerializerOptions): DataModel { + let dms = DataModelStruct() + dms.add(Field("code", DataModelInt(_code))) + dms.add(Field("message", DataModelString(_message))) + if (let Some(data) <- _data) { + if (let ser: ISerializable <- data) { + dms.add(Field("data", ser.serializeObject(options))) + } else { + // 动态结构(HashMap 等):经 SimApiJson 序列化后解析内嵌为对象 + let json = SimApiJson.json(Some(data)) + dms.add(Field("data", DataModel.fromJson(JsonValue.fromStr(json)))) + } + } + dms + } + + /** + * 从 DataModel 反序列化:code + message + data(data 保留原始 DataModel)。 + */ + public static func deserializeObject(dm: DataModel, options: JsonSerializerOptions): SimApiDataResponse { + let resp = SimApiDataResponse() + if (let dms: DataModelStruct <- dm) { + for (field in dms.getFields()) { + match (field.getName()) { + case "code" => + if (let v: DataModelInt <- field.getData()) { + resp._code = v.getValue() + } + case "message" => + if (let v: DataModelString <- field.getData()) { + resp._message = v.getValue() + } + case "data" => + resp._data = Some(field.getData()) + case _ => () + } + } + } + resp + } +} diff --git a/src/communications/SimApiJson.cj b/src/communications/SimApiJson.cj new file mode 100644 index 0000000..c649dd9 --- /dev/null +++ b/src/communications/SimApiJson.cj @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * JSON 序列化统一工具(对应 C# SimApiUtil.Json)。 + */ + +package simapi.communications + +import std.collection.* + +/** + * JSON 序列化静态工具类。 + * + * 说明:序列化核心放在依赖图最底层的 simapi.communications 包, + * SimApiUtil.json(simapi.helpers)委托本类实现,避免循环依赖; + * 全框架 JSON 输出统一走此处,保证转义与格式一致。 + */ +public class SimApiJson { + private init() {} + + /** + * 对象序列化为 JSON 字符串(统一入口)。 + * 支持 String/Int64/Bool/Float64/Array/HashMap,其他类型退化为字符串。 + * @param obj 任意对象(None 输出 null)。 + */ + public static func json(obj: ?Any): String { + if (let Some(obj) <- obj) { + return jsonValue(obj) + } + "null" + } + + /** + * JSON 字符串转义(统一入口)。 + * @param s 原始字符串。 + * @return 转义后可直接放入 JSON 字符串字面量的内容。 + */ + public static func escapeJson(s: String): String { + var sb = StringBuilder() + for (c in s.runes()) { + match (c) { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case _ => sb.append(c) + } + } + sb.toString() + } + + private static func jsonValue(obj: Any): String { + if (let s: String <- obj) { + return "\"${escapeJson(s)}\"" + } + if (let i: Int64 <- obj) { + return "${i}" + } + if (let b: Bool <- obj) { + return "${b}" + } + if (let f: Float64 <- obj) { + return "${f}" + } + if (let arr: Array <- obj) { + var sb = StringBuilder() + sb.append("[") + var first = true + for (item in arr) { + if (!first) { sb.append(",") } + sb.append(jsonValue(item)) + first = false + } + sb.append("]") + return sb.toString() + } + if (let map: HashMap <- obj) { + var sb = StringBuilder() + sb.append("{") + var first = true + for ((key, value) in map) { + if (!first) { sb.append(",") } + sb.append("\"${escapeJson(key)}\":${jsonValue(value)}") + first = false + } + sb.append("}") + return sb.toString() + } + // 其他类型退化为字符串 + return "\"${escapeJson(describe(obj))}\"" + } + + private static func describe(obj: Any): String { + if (let s: ToString <- obj) { + return s.toString() + } + "null" + } +} diff --git a/src/communications/SimApiLoginItem.cj b/src/communications/SimApiLoginItem.cj new file mode 100644 index 0000000..34b9d89 --- /dev/null +++ b/src/communications/SimApiLoginItem.cj @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Communications/SimApiLoginItem:登录信息项。 + */ + +package simapi.communications + +import std.collection.* +import soulsoft_serialization.* + +/** + * 登录信息项:Token 认证通过后注入请求上下文。 + * 对齐 C# SimApiLoginItem(Id / Type / Meta / Extra,camelCase 输出 id/type/meta/extra)。 + * + * 说明:因 _extra 为 HashMap(Any 不满足 soulsoft 的 ISerialization 约束, + * 无法用 @Serialization 宏),故手动实现 ISerialization(serialize + deserialize); + * 调用方统一通过 JsonSerializer.serializeObject() / deserializeObject() 使用 + * (对齐 .NET JsonSerializer.Serialize / Deserialize)。 + */ +public class SimApiLoginItem <: ISerialization { + public var _id: String = "" + public var _types: Array = [] + public var _meta: HashMap = HashMap() + public var _extra: HashMap = HashMap() + + public init() {} + + public init(id: String) { + this._id = id + this._types = ["user"] + this._meta = HashMap() + this._extra = HashMap() + } + + public init(id: String, types: Array) { + this._id = id + this._types = types + this._meta = HashMap() + this._extra = HashMap() + } + + /** + * 序列化为 DataModel:{"id","type","meta","extra"}。 + */ + public func serializeObject(options: JsonSerializerOptions): DataModel { + let dms = DataModelStruct() + dms.add(Field("id", DataModelString(_id))) + // type: string[] + let typesSeq = DataModelSeq() + for (t in _types) { + typesSeq.add(DataModelString(t)) + } + dms.add(Field("type", typesSeq)) + // meta: object + let metaStruct = DataModelStruct() + for ((k, v) in _meta) { + metaStruct.add(Field(k, DataModelString(v))) + } + dms.add(Field("meta", metaStruct)) + // extra: object + let extraStruct = DataModelStruct() + for ((k, v) in _extra) { + extraStruct.add(Field(k, anyToDataModel(v))) + } + dms.add(Field("extra", extraStruct)) + dms + } + + /** + * 从 DataModel 反序列化:{"id","type","meta","extra"}。 + */ + public static func deserializeObject(dm: DataModel, options: JsonSerializerOptions): SimApiLoginItem { + let item = SimApiLoginItem() + if (let dms: DataModelStruct <- dm) { + for (field in dms.getFields()) { + match (field.getName()) { + case "id" => item._id = field.getData() |> dataModelToString + case "type" => + let list = ArrayList() + if (let seq: DataModelSeq <- field.getData()) { + for (sub in seq.getItems()) { + list.add(dataModelToString(sub)) + } + } + item._types = list.toArray() + case "meta" => + if (let metaStruct: DataModelStruct <- field.getData()) { + for (sub in metaStruct.getFields()) { + item._meta[sub.getName()] = dataModelToString(sub.getData()) + } + } + case "extra" => + if (let extraStruct: DataModelStruct <- field.getData()) { + for (sub in extraStruct.getFields()) { + item._extra[sub.getName()] = dataModelToAny(sub.getData()) + } + } + case _ => () + } + } + } + item + } + + private static func dataModelToString(dm: DataModel): String { + if (let s: DataModelString <- dm) { + return s.getValue() + } + if (let i: DataModelInt <- dm) { + return "${i.getValue()}" + } + if (let b: DataModelBool <- dm) { + return "${b.getValue()}" + } + "" + } + + private static func dataModelToAny(dm: DataModel): Any { + if (let s: DataModelString <- dm) { + return s.getValue() + } + if (let i: DataModelInt <- dm) { + return i.getValue() + } + if (let b: DataModelBool <- dm) { + return b.getValue() + } + if (let f: DataModelFloat <- dm) { + return f.getValue() + } + if (let dms: DataModelStruct <- dm) { + var map = HashMap() + for (field in dms.getFields()) { + map[field.getName()] = dataModelToAny(field.getData()) + } + return map + } + if (let seq: DataModelSeq <- dm) { + let list = ArrayList() + for (item in seq.getItems()) { + list.add(dataModelToAny(item)) + } + return list.toArray() + } + "" + } + + private static func anyToDataModel(v: Any): DataModel { + if (let s: String <- v) { + return DataModelString(s) + } + if (let i: Int64 <- v) { + return DataModelInt(i) + } + if (let b: Bool <- v) { + return DataModelBool(b) + } + if (let f: Float64 <- v) { + return DataModelFloat(f) + } + if (let s: ToString <- v) { + return DataModelString(s.toString()) + } + DataModelNull() + } +} diff --git a/src/configurations/SimApiAuthCenterOptions.cj b/src/configurations/SimApiAuthCenterOptions.cj new file mode 100644 index 0000000..db7dbd0 --- /dev/null +++ b/src/configurations/SimApiAuthCenterOptions.cj @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiAuthCenterOptions.cs。 + */ + +package simapi.configurations + +/** + * 认证中心配置(对齐 C# SimApiAuthCenterOptions)。 + */ +public class SimApiAuthCenterOptions { + /** + * 认证中心地址。 + */ + public var server: String = "" + + /** + * 应用 Id。 + */ + public var appId: String = "" + + /** + * 应用密钥。 + */ + public var appKey: String = "" + + /** + * 开启则使用内部网关透传的 Middleware。 + * 注意:只有内部应用需要开启这个,也就是 api 通过内部网关代理后。 + */ + public var useMiddleware: Bool = false + + /** + * 是否使用 IAM 认证。 + */ + public var useIam: Bool = false + + public init() {} +} diff --git a/src/configurations/SimApiDocOptions.cj b/src/configurations/SimApiDocOptions.cj new file mode 100644 index 0000000..2124414 --- /dev/null +++ b/src/configurations/SimApiDocOptions.cj @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiDocOptions.cs。 + */ + +package simapi.configurations + +import std.collection.* + +/** + * 文档组配置(对齐 C# SimApiDocGroupOption)。 + */ +public class SimApiDocGroup { + public var id: String = "" + public var name: String = "" + public var description: String = "" + + public init() {} + + public init(id: String, name: String, description!: String = "") { + this.id = id + this.name = name + this.description = description + } +} + +/** + * 文档授权配置(对齐 C# SimApiAuthOption)。 + * Type 支持 "SimApiAuth"、"ClientCredentials"、"Implicit"、"AuthorizationCode"、"Password"。 + */ +public class SimApiAuthOption { + /** + * 认证方式(默认 ["SimApiAuth"])。 + * `type` 是仓颉关键字,用反引号转义以对齐 C# 属性名 Type。 + */ + public var `type`: Array = ["SimApiAuth"] + + /** + * 认证描述(默认 "认证服务器颁发的AccessToken")。 + */ + public var description: String = "认证服务器颁发的AccessToken" + + /** + * 授权地址。 + */ + public var authorizationUrl: String = "" + + /** + * Token 地址。 + */ + public var tokenUrl: String = "" + + /** + * 授权范围。 + */ + public var scopes: HashMap = HashMap() + + public init() {} +} + +/** + * 文档相关配置(默认值与 C# 一致)。 + */ +public class SimApiDocOptions { + /** + * 文档组配置(默认 [new("api", "Api", "Api接口文档")])。 + */ + public var apiGroups: ArrayList = ArrayList() + + /** + * 授权配置(默认 SimApiAuthOption())。 + */ + public var apiAuth = SimApiAuthOption() + + /** + * 文档页面标题(默认 "API接口文档")。 + */ + public var documentTitle: String = "API接口文档" + + /** + * 接口支持的调用方式(默认仅 POST)。 + */ + public var supportedMethods: Array = ["POST"] + + public init() { + apiGroups.add(SimApiDocGroup("api", "Api", description: "Api接口文档")) + } +} diff --git a/src/configurations/SimApiExceptionOptions.cj b/src/configurations/SimApiExceptionOptions.cj new file mode 100644 index 0000000..b592d43 --- /dev/null +++ b/src/configurations/SimApiExceptionOptions.cj @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiExceptionOptions.cs。 + */ + +package simapi.configurations + +import std.collection.* + +/** + * 异常处理相关配置(默认值与 C# 一致)。 + */ +public class SimApiExceptionOptions { + /** + * 跳过不做业务错误处理的状态码(默认 [200, 301, 302])。 + */ + public var skipStatusCodes: HashSet = HashSet([200, 301, 302]) + + public init() {} +} diff --git a/src/configurations/SimApiHttpClientOptions.cj b/src/configurations/SimApiHttpClientOptions.cj new file mode 100644 index 0000000..9f75478 --- /dev/null +++ b/src/configurations/SimApiHttpClientOptions.cj @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiHttpClientOptions.cs。 + */ + +package simapi.configurations + +/** + * HTTP 客户端配置。 + */ +public class SimApiHttpClientOptions { + public var server: String = "" + public var appId: String = "" + public var appKey: String = "" + public var signName: String = "sign" + public var timestampName: String = "timestamp" + public var nonceName: String = "nonce" + public var appIdName: ?String = Some("appId") + public var signFields: Array = [] + + public init() {} +} diff --git a/src/configurations/SimApiJobOptions.cj b/src/configurations/SimApiJobOptions.cj new file mode 100644 index 0000000..94d651c --- /dev/null +++ b/src/configurations/SimApiJobOptions.cj @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiJobOptions.cs。 + */ + +package simapi.configurations + +import std.collection.* + +/** + * 任务调度服务器配置(对齐 C# SimApiJobServerConfig)。 + */ +public class SimApiJobServer { + /** + * 队列(默认 ["default"])。 + */ + public var queues: Array = ["default"] + + /** + * 工作线程数(默认 5)。 + */ + public var workerNum: Int64 = 5 + + public init() {} +} + +/** + * 任务调度相关配置(默认值与 C# 一致)。 + */ +public class SimApiJobOptions { + /** + * WebUi 地址,设置为 null 表示不启用(默认 /jobs)。 + */ + public var dashboardUrl: ?String = Some("/jobs") + + /** + * WebUi 用户(默认 admin)。 + */ + public var dashboardAuthUser: String = "admin" + + /** + * WebUi 密码(默认 Admin@123!)。 + */ + public var dashboardAuthPass: String = "Admin@123!" + + /** + * 设置为 null 使用默认 redis 配置。 + */ + public var redisConfiguration: ?String = None + + /** + * 设置为 null 使用默认 redis 配置。 + */ + public var database: ?Int64 = None + + /** + * 任务服务器配置(默认 [new()])。 + */ + public var servers: ArrayList = ArrayList() + + public init() { + servers.add(SimApiJobServer()) + } +} diff --git a/src/configurations/SimApiOptions.cj b/src/configurations/SimApiOptions.cj new file mode 100644 index 0000000..d51908d --- /dev/null +++ b/src/configurations/SimApiOptions.cj @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiOptions.cs。 + */ + +package simapi.configurations + +import std.collection.* + +/** + * SimApi 全局配置:对应 C# 的 SimApi.Configurations.SimApiOptions。 + */ +public class SimApiOptions { + /** + * Redis 配置;配置则使用 Redis,不配则自动使用 InMemory。 + */ + public var redisConfiguration: String = "" + + /** + * 原样返回给前端的配置信息。 + */ + public var webConfig: HashMap = HashMap() + + /** + * WebConfig 返回是否包含版本信息。 + */ + public var webConfigIncludeVersion: Bool = true + + /** + * 是否启用后台任务系统(占位,未实现)。 + */ + public var enableJob: Bool = false + + /** + * 启用 Token 认证。 + */ + public var enableSimApiAuth: Bool = false + + /** + * 启用缓存功能(默认 true)。 + */ + public var enableSimApiCache: Bool = true + + /** + * 启用网关授权(占位,未实现)。 + */ + public var enableSimApiAuthGate: Bool = false + + /** + * 开启 S3 兼容存储(占位,未实现)。 + */ + public var enableSimApiStorage: Bool = false + + /** + * 启用在线文档(占位,未实现)。 + */ + public var enableSimApiDoc: Bool = false + + /** + * 是否启用 Synapse(占位,未实现)。 + */ + public var enableSynapse: Bool = false + + /** + * 启用全部 CORS(默认 true)。 + */ + public var enableCors: Bool = true + + /** + * 启用异常拦截(默认 true)。 + */ + public var enableSimApiException: Bool = true + + /** + * 启用返回结果拦截(默认 true)。 + */ + public var enableSimApiResponseFilter: Bool = true + + /** + * 启用请求日志中间件。 + */ + public var enableRequestLog: Bool = false + + /** + * 开启 ForwardHeaders(默认 true)。 + */ + public var enableForwardHeaders: Bool = true + + /** + * 将 url 格式化为小写(默认 true)。 + */ + public var enableLowerUrl: Bool = true + + /** + * 启用格式化 Console Logger(默认 true)。 + */ + public var enableLogger: Bool = true + + /** + * 是否启用 SimApiHttpClient。 + */ + public var enableSimApiHttpClient: Bool = false + + public var simApiJobOptions = SimApiJobOptions() + public var simApiDocOptions = SimApiDocOptions() + public var simApiStorageOptions = SimApiStorageOptions() + public var simApiSynapseOptions = SimApiSynapseOptions() + public var simApiAuthCenterOptions = SimApiAuthCenterOptions() + public var simApiHttpClientOptions = SimApiHttpClientOptions() + public var simApiExceptionOptions = SimApiExceptionOptions() + public var simApiRouteOptions = SimApiRouteOptions() + public var simApiRequestLogOptions = SimApiRequestLogOptions() + + public init() {} + + // ===== .NET 风格配置回调(对齐 C# ConfigureSimApiXxx(opt => ...)) ===== + + /** + * 配置路由选项。 + * @param configure 路由配置回调。 + */ + public func configureSimApiRoute(configure: (SimApiRouteOptions) -> Unit): Unit { + configure(simApiRouteOptions) + } + + /** + * 配置异常处理选项。 + * @param configure 异常配置回调。 + */ + public func configureSimApiException(configure: (SimApiExceptionOptions) -> Unit): Unit { + configure(simApiExceptionOptions) + } + + /** + * 配置 HTTP 客户端选项。 + * @param configure 客户端配置回调。 + */ + public func configureSimApiHttpClient(configure: (SimApiHttpClientOptions) -> Unit): Unit { + configure(simApiHttpClientOptions) + } + + /** + * 配置文档选项(占位)。 + * @param configure 文档配置回调。 + */ + public func configureSimApiDoc(configure: (SimApiDocOptions) -> Unit): Unit { + configure(simApiDocOptions) + } + + /** + * 配置存储选项(占位)。 + * @param configure 存储配置回调。 + */ + public func configureSimApiStorage(configure: (SimApiStorageOptions) -> Unit): Unit { + configure(simApiStorageOptions) + } + + /** + * 配置任务调度选项(占位)。 + * @param configure 任务配置回调。 + */ + public func configureSimApiJob(configure: (SimApiJobOptions) -> Unit): Unit { + configure(simApiJobOptions) + } + + /** + * 配置 Synapse 选项(占位)。 + * @param configure Synapse 配置回调。 + */ + public func configureSimApiSynapse(configure: (SimApiSynapseOptions) -> Unit): Unit { + configure(simApiSynapseOptions) + } + + /** + * 配置请求日志选项(对齐 C# ConfigureSimApiRequestLog)。 + * @param configure 请求日志配置回调。 + */ + public func configureSimApiRequestLog(configure: (SimApiRequestLogOptions) -> Unit): Unit { + configure(simApiRequestLogOptions) + } + + /** + * 配置认证中心选项(对齐 C# ConfigureSimApiAuthCenter)。 + * @param configure 认证中心配置回调。 + */ + public func configureSimApiAuthCenter(configure: (SimApiAuthCenterOptions) -> Unit): Unit { + configure(simApiAuthCenterOptions) + } +} diff --git a/src/configurations/SimApiRequestLogOptions.cj b/src/configurations/SimApiRequestLogOptions.cj new file mode 100644 index 0000000..6209a53 --- /dev/null +++ b/src/configurations/SimApiRequestLogOptions.cj @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiRequestLogOptions.cs。 + */ + +package simapi.configurations + +/** + * 请求日志配置。 + */ +public class SimApiRequestLogOptions { + /** + * 是否打印完整的请求 Header。 + */ + public var showFullHeader: Bool = false + + /** + * 是否打印完整的响应体。 + */ + public var showFullResponse: Bool = false + + /** + * 请求字段显示最长长度(0 表示不截断)。 + */ + public var requestStringLogLength: Int64 = 0 + + public init() {} +} diff --git a/src/configurations/SimApiRouteOptions.cj b/src/configurations/SimApiRouteOptions.cj new file mode 100644 index 0000000..4b176c6 --- /dev/null +++ b/src/configurations/SimApiRouteOptions.cj @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiRouteOptions.cs。 + */ + +package simapi.configurations + +/** + * 路由相关配置(默认值与 C# 一致)。 + */ +public class SimApiRouteOptions { + /** + * 退出登录路由(默认 /auth/logout)。 + */ + public var logoutRoute: ?String = Some("/auth/logout") + + /** + * 用户信息路由(默认 /user/info)。 + */ + public var userInfoRoute: ?String = Some("/user/info") + + /** + * 前端配置路由(默认 /config)。 + */ + public var webConfigRoute: ?String = Some("/config") + + public init() {} +} diff --git a/src/configurations/SimApiStorageOptions.cj b/src/configurations/SimApiStorageOptions.cj new file mode 100644 index 0000000..0e56383 --- /dev/null +++ b/src/configurations/SimApiStorageOptions.cj @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiStorageOptions.cs。 + */ + +package simapi.configurations + +/** + * 存储相关配置(占位:仓颉版暂未实现 S3/MinIO 存储)。 + */ +public class SimApiStorageOptions { + public var endpoint: String = "" + public var accessKey: String = "" + public var secretKey: String = "" + public var bucket: String = "" + public var serveUrl: String = "" + + public init() {} +} diff --git a/src/configurations/SimApiSynapseOptions.cj b/src/configurations/SimApiSynapseOptions.cj new file mode 100644 index 0000000..4c5a0e1 --- /dev/null +++ b/src/configurations/SimApiSynapseOptions.cj @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 对齐 C# 的 Configurations/SimApiSynapseOptions.cs。 + */ + +package simapi.configurations + +/** + * MQTT 通信配置(默认值与 C# 一致)。 + */ +public class SimApiSynapseOptions { + /** + * Mqtt 服务器的 Websocket 地址。 + */ + public var websocket: String = "" + + public var username: String = "" + public var password: String = "" + public var sysName: String = "" + public var appName: String = "" + public var appId: String = "" + + /** + * RPC 超时秒数(默认 3)。 + */ + public var rpcTimeout: Int64 = 3 + + /** + * Event 是否使用负载均衡:订阅 $queue 主题,消息分发给不同 AppId。 + * false 时多个 AppId 都可同时收到消息(默认 false)。 + */ + public var eventLoadBalancing: Bool = false + + /** + * 启用分布式配置存储(默认 true)。 + */ + public var enableConfigStore: Bool = true + + /** + * 禁用事件客户端(默认 false)。 + */ + public var disableEventClient: Bool = false + + /** + * 禁用 RPC 客户端(默认 false)。 + */ + public var disableRpcClient: Bool = false + + public init() {} +} diff --git a/src/controllers/SimApiAuthController.cj b/src/controllers/SimApiAuthController.cj new file mode 100644 index 0000000..f637b16 --- /dev/null +++ b/src/controllers/SimApiAuthController.cj @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Controllers/SimApiAuthController:认证相关内置路由。 + */ + +package simapi.controllers + +import soulsoft_web_mvc.annotations.* +import simapi.communications.* +import simapi.helpers.* + +/** + * 认证控制器:退出登录。 + * 对齐 C# 的 SimApiAuthController。 + */ +public class SimApiAuthController <: SimApiBaseController { + private let _auth: SimApiAuth + + public init(auth: SimApiAuth) { + this._auth = auth + } + + /** + * POST /auth/logout:退出登录(void 自动封装为 SimApiBaseResponse())。 + * 对齐 C# LogoutRoute 默认值 /auth/logout(SimApiAuthController.Logout,[HttpPost], + * 由 MapControllerRoute(pattern=LogoutRoute) 注册)。 + */ + @HttpPost["/auth/logout"] + public func logout(): Unit { + if (let Some(token) <- request.headers.get("Token")) { + _auth.logout(token) + } + } +} diff --git a/src/controllers/SimApiBaseController.cj b/src/controllers/SimApiBaseController.cj new file mode 100644 index 0000000..069759f --- /dev/null +++ b/src/controllers/SimApiBaseController.cj @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Controllers/SimApiBaseController:基础控制器,所有控制器继承。 + * 提供当前登录信息访问(对齐 C# 的 LoginInfo / LoginToken)。 + */ + +package simapi.controllers + +import soulsoft_web_http.* +import soulsoft_web_mvc.core.* +import simapi.communications.* +import simapi.helpers.* + +/** + * 基础控制器:所有控制器均继承本控制器。 + * 对齐 C# 的 SimApiBaseController([Consumes]/[Produces] JSON + 登录信息)。 + */ +public open class SimApiBaseController <: Controller { + /** + * 当前登录信息(需 EnableSimApiAuth;未登录抛 401)。 + */ + protected prop loginInfo: SimApiLoginItem { + get() { + if (let Some(item) <- context.items.get("LoginInfo")) { + if (let login: SimApiLoginItem <- item) { + return login + } + } + SimApiError.error(code: 401, message: "需要登录") + SimApiLoginItem("") + } + } + + /** + * 当前登录 Token(需 EnableSimApiAuth;未登录抛 401)。 + */ + protected prop loginToken: String { + get() { + if (let Some(item) <- context.items.get("LoginToken")) { + if (let token: String <- item) { + return token + } + } + SimApiError.error(code: 401, message: "需要登录") + "" + } + } + + /** + * 当前登录信息(可选)。 + */ + protected func getLogin(): ?SimApiLoginItem { + if (let Some(item) <- context.items.get("LoginInfo")) { + if (let login: SimApiLoginItem <- item) { + return Some(login) + } + } + None + } + + /** + * 检查登录状态,未登录抛 401。 + */ + protected func requireLogin(): Unit { + match (getLogin()) { + case None => SimApiError.error(code: 401, message: "需要登录") + case _ => () + } + } + + /** + * 绑定当前请求上下文(供 SimApiRequestDelegateFactory 调用; + * Controller.setup 为 protected,此处以 public 方法暴露给框架)。 + */ + public func bindRequestContext(context: HttpContext): Unit { + this.setup(context) + } +} diff --git a/src/controllers/SimApiCommonController.cj b/src/controllers/SimApiCommonController.cj new file mode 100644 index 0000000..9f6fab4 --- /dev/null +++ b/src/controllers/SimApiCommonController.cj @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Controllers/SimApiCommonController:通用内置路由。 + */ + +package simapi.controllers + +import std.collection.* +import soulsoft_web_mvc.annotations.* +import simapi.communications.* +import simapi.configurations.* +import simapi.helpers.* + +/** + * 通用控制器:错误反馈、WebConfig、用户信息。 + * 对齐 C# 的 SimApiCommonController。 + * 控制器直接返回 SimApiBaseResponse / SimApiResponse(对齐 C# SimApiBaseResponse)。 + */ +public class SimApiCommonController <: SimApiBaseController { + private let _options: SimApiOptions + + public init(options: SimApiOptions) { + this._options = options + } + + /** + * GET /exception/{code}:错误反馈页面(始终注册)。 + * 返回 SimApiBaseResponse(已是响应体,原样输出)。 + */ + @HttpGet["exception/{code}"] + public func exceptionHandler(@FromRoute code: Int64): SimApiBaseResponse { + SimApiBaseResponse(code, SimApiBaseResponse.getDefaultMessage(code)) + } + + /** + * GET/POST /versions:返回 SimApi/App 版本信息(HashMap 由 SimApiResponseFilter 自动封装)。 + */ + @HttpGet["versions"] + public func versions(): HashMap { + versionsMap() + } + + @HttpPost["versions"] + public func versionsPost(): HashMap { + versionsMap() + } + + /** + * POST/GET /config:给前端的自定义信息(含版本)。 + * 对齐 C# WebConfigRoute 默认值 /config(SimApiCommonController.WebConfig,[HttpPost, HttpGet] 无路径, + * 由 MapControllerRoute(pattern=WebConfigRoute) 注册;soulsoft 约定路由不支持 defaults,故用特性路由直接对齐路径)。 + */ + @HttpGet["/config"] + public func webConfig(): HashMap { + webConfigMap() + } + + @HttpPost["/config"] + public func webConfigPost(): HashMap { + webConfigMap() + } + + /** + * POST /user/info:获取已登录用户信息(需登录)。 + * 返回 SimApiLoginItem 由 SimApiResponseFilter 自动封装(data 为登录信息对象)。 + */ + @HttpPost["/user/info"] + public func userInfo(): SimApiLoginItem { + requireLogin() + loginInfo + } + + private func versionsMap(): HashMap { + var map = HashMap() + map["SimApi"] = SimApiUtil.simApiVersion + map["App"] = SimApiUtil.appVersion + map + } + + private func webConfigMap(): HashMap { + var map = HashMap() + for ((key, value) in _options.webConfig) { + map[key] = value + } + if (_options.webConfigIncludeVersion) { + map["Versions"] = versionsMap() + } + map + } +} diff --git a/src/exceptions/SimApiException.cj b/src/exceptions/SimApiException.cj new file mode 100644 index 0000000..8ccf10e --- /dev/null +++ b/src/exceptions/SimApiException.cj @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.exceptions + +/** + * API 错误捕获异常:携带业务错误码,由异常中间件统一转换为 HTTP 200 + JSON。 + */ +public class SimApiException <: Exception { + public let code: Int64 + + public init(code: Int64, message!: String = "") { + super(message) + this.code = code + } +} diff --git a/src/extensions/SimApiExtensions.cj b/src/extensions/SimApiExtensions.cj new file mode 100644 index 0000000..6989dce --- /dev/null +++ b/src/extensions/SimApiExtensions.cj @@ -0,0 +1,315 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.extensions + +import std.collection.* +import std.convert.* +import std.reflect.* +import std.time.* +import soulsoft_serialization.* +import soulsoft_serialization.macros.* +import soulsoft_web_http.* +import soulsoft_web_hosting.* +import soulsoft_web_mvc.* +import soulsoft_web_mvc.applicationModels.* +import soulsoft_web_mvc.routing.* +import soulsoft_web_cors.* +import soulsoft_web_routing.* +import soulsoft_extensions_injection.* +import soulsoft_extensions_logging.* +import simapi.communications.* +import simapi.configurations.* +import simapi.controllers.* +import simapi.helpers.* +import simapi.logger.* +import simapi.middlewares.* + +/** + * SimApi 扩展入口:对应 C# 的 SimApiExtensions(AddSimApi + UseSimApi)。 + * + * 使用方式(仓颉版,与 addLogging 风格一致): + * ``` + * let builder = WebHost.createBuilder(args) + * builder.services.addLogging() + * builder.addSimApi { options => + * options.enableSimApiAuth = true + * } + * let host = builder.build() + * host.useSimApi() + * host.run() + * ``` + */ +public interface SimApiBuilderExtensions { + /** + * 注册 SimApi 服务到 WebHostBuilder(自动 addRouting + addControllers + 扫描控制器)。 + * @param configure 配置回调。 + * @return 当前构建器。 + */ + func addSimApi(configure: (SimApiOptions) -> Unit): WebHostBuilder + + /** + * 注册 SimApi 服务(默认配置,自动扫描控制器)。 + * @return 当前构建器。 + */ + func addSimApi(): WebHostBuilder +} + +extend WebHostBuilder <: SimApiBuilderExtensions { + /** + * 注册 SimApi 服务(自动 addRouting + addControllers + addLogging + 扫描控制器)。 + * @param configure 配置回调。 + */ + public func addSimApi(configure: (SimApiOptions) -> Unit): WebHostBuilder { + // 自动注册路由(对齐 builder.Services.AddRouting()) + this.services.addRouting() + // 先构造配置,供后续按开关注册服务(对齐 C# AddSimApi 中先读 options 再注册) + let options = SimApiOptions() + configure(options) + // 响应封装(对齐 C# SimApiResponseFilter,受 EnableSimApiResponseFilter 开关控制): + // 启用时注册自定义 IRequestDelegateFactory 自动封装响应。 + // 必须在 addControllers 之前:soulsoft 用 tryAddSingleton 注册,先到先得,不会被覆盖。 + // 未启用时使用 soulsoft 默认派发(String→ContentResult / ISerializable→ObjectResult / 其余→204)。 + if (options.enableSimApiResponseFilter) { + this.services.addSingleton() + } + // 自动注册 MVC + 控制器(对齐 builder.Services.AddControllers()) + // 自动扫描调用者包中的 Controller 子类(对齐 C# 的 Assembly.GetTypes() 扫描) + let controllers = SimApiControllerScanner.scan() + this.services.addControllers(controllers) + // 注册 SimApi 服务 + addSimApiCore(this, options) + } + + /** + * 注册 SimApi 服务(默认配置,自动扫描控制器)。 + */ + public func addSimApi(): WebHostBuilder { + addSimApi({_ =>}) + } +} + +private func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): WebHostBuilder { + // 注册单例配置(对齐 C# builder.AddSingleton(simApiOptions)) + builder.services.addSingleton(options) + // 子配置不单独注册:中间件统一注入 SimApiOptions 后访问其属性 + // (对齐 C# SimApiExceptionMiddleware(..., SimApiOptions simApiOptions) 风格) + + // 自定义日志格式(替换默认 console provider) + if (options.enableLogger) { + builder.services.addLogging { + logging => + logging.clearProviders() + logging.addProvider(SimApiLoggerProvider()) + } + } + + // 中间件无需注册:挂载时由 ActivatorUtilities 从 DI 解析构造参数创建 + // (对齐 C# builder.UseMiddleware(),其中间件由 UseMiddleware 创建) + + // 认证(DI 自动注入 SimApiOptions) + if (options.enableSimApiAuth) { + builder.services.addSingleton() + } + + // 缓存(DI 自动注入 SimApiOptions) + if (options.enableSimApiCache) { + builder.services.addSingleton() + } + + // HTTP 客户端(DI 自动注入 SimApiOptions) + if (options.enableSimApiHttpClient) { + builder.services.addSingleton() + } + + // CORS(对齐 C# builder.Services.AddCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())) + if (options.enableCors) { + builder.services.addCors { + cors => + cors.addDefaultPolicy { + policy => + policy.allowAnyOrigin() + policy.allowAnyMethod() + policy.allowAnyHeader() + } + } + } + + return builder +} + +/** + * SimApi 主机扩展。 + */ +public interface SimApiHostExtensions { + /** + * 应用 SimApi 中间件与内置路由到 WebHost。 + */ + func useSimApi(): Unit +} + +extend WebHost <: SimApiHostExtensions { + /** + * 应用 SimApi 中间件与内置路由(日志输出对齐 C# UseSimApi)。 + */ + public func useSimApi(): Unit { + let options = this.services.getOrThrow() + let loggerFactory = this.services.getOrThrow() + // 对齐 C# ILogger:分类名为 SimApiOptions 的全限定名 + let logger = loggerFactory.createLogger() + + // ===== 基础信息(对齐 C# UseSimApi(IHost) 开头) ===== + let now = DateTime.now() + logger.info("当前时区: ${now.zoneId}") + logger.info("主应用版本: ${SimApiUtil.appVersion}\nSimApi版本: ${SimApiUtil.simApiVersion}") + + // RedisCache + if (!options.redisConfiguration.isEmpty()) { + logger.info("开始配置 RedisCache ...") + } + + // SimApiCache + if (options.enableSimApiCache) { + logger.info("开始配置 SimApiCache...") + } + + // SimApiStorage(占位) + if (options.enableSimApiStorage) { + logger.info("开始配置 SimApiStorage...") + } + + // SimApiHttpClient + if (options.enableSimApiHttpClient) { + logger.info( + "开始配置 SimApiHttpClient...\n服务器地址: ${options.simApiHttpClientOptions.server}\nAppId: ${options.simApiHttpClientOptions.appId}\nAppkey: ${options.simApiHttpClientOptions.appKey}") + } + + // Synapse(占位) + if (options.enableSynapse) { + logger.info("开始配置 SimApiSynapse...") + } + + // SimApiJob(占位) + if (options.enableJob) { + logger.info("开始配置 SimApiJob ...") + } + + // ===== 中间件与路由(对齐 C# UseSimApi(WebApplication) 的挂载顺序) ===== + // C# 挂载顺序(先挂载 = 外层):CORS(L425) → AuthGate(L454) → Auth(L462) → RequestLog(L519) → Exception(L525) + // OPTIONS 预检请求在 CORS 处短路(204,不调用 next),因此 RequestLog/Exception 均不会执行 + + // CORS(对齐 C# builder.UseCors("any"),最先挂载) + if (options.enableCors) { + logger.info("开始配置 Cors全部允许...") + this.useCors() + } + + // AuthGate(占位,对齐 C# UseMiddleware) + if (options.enableSimApiAuthGate) { + logger.info("开始配置 SimApiAuthGate...") + } + + // 认证中间件(对齐 C# builder.UseMiddleware()) + if (options.enableSimApiAuth) { + logger.info("开始配置 SimApiAuth...") + this.use() + } + + // 请求日志中间件(对齐 C# builder.UseMiddleware()) + if (options.enableRequestLog) { + logger.info("开始配置 SimApiRequestLog...") + this.use() + } + + // 异常中间件最后挂载(最内层,对齐 C# builder.UseMiddleware()) + if (options.enableSimApiException) { + logger.info("开始配置 SimApiException...") + this.use() + } + + // 内置路由 + if (let Some(route) <- options.simApiRouteOptions.userInfoRoute) { + logger.info("注册内置Route: UserInfo => ${route}") + } + if (let Some(route) <- options.simApiRouteOptions.logoutRoute) { + logger.info("注册内置Route: Logout => ${route}") + } + if (let Some(route) <- options.simApiRouteOptions.webConfigRoute) { + logger.info("注册内置Route: WebConfig => ${route}") + } + + // SimApiDoc(占位) + if (options.enableSimApiDoc) { + logger.info("开始配置 SimApiDoc...") + } + + // URL 小写 + if (options.enableLowerUrl) { + logger.info("开始配置使用URL小写...") + } + + // SimApiJob Web 控制台(占位) + if (options.enableJob && options.simApiJobOptions.dashboardUrl != None) { + logger.info("开始配置 SimApiJob Web控制台...") + } + + // 响应封装(已实现:addSimApi 中按开关注册 SimApiRequestDelegateFactory 自动封装, + // 对齐 C# SimApiResponseFilter;此处仅输出配置日志) + if (options.enableSimApiResponseFilter) { + logger.info("开始配置 SimApiResponseFilter...") + } + + // ForwardedHeaders(占位:soulsoft 暂无内置) + if (options.enableForwardHeaders) { + logger.info("开始配置ForwardedHeaders...") + } + + // 映射控制器端点(对齐 C# UseSimApi 中的 MapControllers) + let callSiteFactory = this.services.getOrThrow() + if (callSiteFactory.isService()) { + this.mapControllers() + } + } +} + +/** + * SimApi MVC 注册扩展:对齐 .NET 的 builder.Services.AddControllers()。 + * 注册 MVC 服务 + SimApi 内置控制器 + 用户控制器。 + * + * 说明:控制器端点映射使用 soulsoft_web_mvc 的 mapControllers()(对齐 .NET MapControllers()), + * 宿主在 WebHost 上直接调用 host.mapControllers() 即可。 + */ +public interface SimApiMvcBuilderExtensions { + /** + * 注册 MVC 服务与控制器。 + * @param controllerTypes 用户控制器类型列表(可选)。 + * @return MVC 构建器。 + */ + func addControllers(controllerTypes: Array): MvcBuilder +} + +extend ServiceCollection <: SimApiMvcBuilderExtensions { + /** + * 注册 MVC 服务,并注册 SimApi 内置控制器 + 用户控制器到 ApplicationPartManager。 + * 对齐 .NET 的 AddControllers()(含控制器发现)。 + * @param controllerTypes 用户控制器类型列表。 + * @return MVC 构建器。 + */ + public func addControllers(controllerTypes: Array): MvcBuilder { + // 调用 soulsoft_web_mvc 的无参 addControllers() 注册 MVC 核心服务 + let mvc = this.addControllers() + let types = ArrayList() + // SimApi 内置控制器 + types.add(TypeInfo.of()) + types.add(TypeInfo.of()) + // 用户控制器 + for (t in controllerTypes) { + types.add(t) + } + let part = AssemblyPart("simapi.controllers", types.toArray()) + mvc.addApplicationPart(part) + mvc + } +} diff --git a/src/extensions/SimApiRequestDelegateFactory.cj b/src/extensions/SimApiRequestDelegateFactory.cj new file mode 100644 index 0000000..74a678e --- /dev/null +++ b/src/extensions/SimApiRequestDelegateFactory.cj @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * 自定义 IRequestDelegateFactory:在结果派发时自动封装响应, + * 对齐 C# SimApiResponseFilter(IResultFilter)的行为: + * - 返回 SimApiBaseResponse 或其子类 → 原样输出 + * - 返回 null/void(Unit)→ SimApiBaseResponse()({code:200, message:成功}) + * - 返回 String → SimApiResponse(data 为字符串) + * - 返回其他对象 → SimApiResponse(data 为对象) + * + * 注册方式:必须在 soulsoft addControllers() 之前注册(tryAddSingleton 先到先得)。 + */ + +package simapi.extensions + +import std.collection.* +import std.reflect.* +import soulsoft_web_http.* +import soulsoft_web_mvc.core.* +import soulsoft_web_mvc.routing.* +import soulsoft_web_mvc.controllers.* +import soulsoft_web_mvc.modelBindings.* +import soulsoft_web_mvc.abstractions.* +import soulsoft_extensions_options.* +import soulsoft_extensions_injection.* +import simapi.communications.* +import simapi.controllers.* + +/** + * 自定义请求委托工厂:接管 soulsoft 的 ControllerRequestDelegateFactory, + * 在结果派发时自动封装响应(对齐 C# SimApiResponseFilter)。 + */ +public class SimApiRequestDelegateFactory <: IRequestDelegateFactory { + private let _mvcOptions: MvcOptions + private let _modelBinder: IActionModelBinder + + public init(mvcOptions: IOptions, modelBinder: IActionModelBinder, services: IServiceProvider) { + _mvcOptions = mvcOptions.value + _modelBinder = modelBinder + } + + public func createRequestDelegate(actionDescriptor: ControllerActionDescriptor): RequestDelegate { + return { + context => SimApiActionInvoker(context, _modelBinder, actionDescriptor, _mvcOptions).apply() + } + } +} + +/** + * 单次请求的动作执行器(复制 soulsoft ControllerActionInvoker, + * dispatchResult 改为自动封装响应)。 + */ +struct SimApiActionInvoker { + SimApiActionInvoker(let context: HttpContext, let modelBinder: IActionModelBinder, + let actionDescriptor: ControllerActionDescriptor, let mvcOptions: MvcOptions) { + } + + public func apply(): Unit { + let controller = createControllerInstance() + let modelBindingContext = ActionBindingContext(context, actionDescriptor.actionFunction.parameters) + let boundParameters = modelBinder.bind(modelBindingContext) + if (!modelBindingContext.modelState.isValid) { + handleInvalidModelState(modelBindingContext) + } else { + let actionResult = actionDescriptor.actionFunction.apply(controller, boundParameters) + dispatchResult(actionResult) + } + } + + /// 模型绑定失败时写入 ProblemDetails 响应 + private func handleInvalidModelState(modelBindingContext: ActionBindingContext) { + let options = context.services.getOrThrow>() + if (let Some(factory) <- options.value.invalidModelStateResponseFactory) { + let actionContext = ActionContext(context, modelBindingContext.modelState) + factory(actionContext).invoke(context) + } else { + let details = createValidationProblemDetails(modelBindingContext) + if (let Some(status) <- details.status) { + context.response.statusCode = UInt16(status) + } + context.response.writeAsJson(details) + } + } + + /// 结果派发 + 自动封装(对齐 C# SimApiResponseFilter) + private func dispatchResult(actionResult: Any) { + if (let result: IActionResult <- actionResult) { + // 显式返回 IActionResult(如 ContentResult)→ 原样 + result.invoke(context) + } else if (let result: SimApiBaseResponse <- actionResult) { + // 已是 SimApiBaseResponse(含子类)→ 原样输出 + ObjectResult(result).invoke(context) + } else if (let result: String <- actionResult) { + // String → SimApiResponse(data 为字符串) + ObjectResult(SimApiResponse(result)).invoke(context) + } else if (let result: Unit <- actionResult) { + // void/无返回 → SimApiBaseResponse()({code:200, message:成功}) + context.response.writeAsJson(SimApiBaseResponse()) + } else { + // 其他对象(DTO/数组/动态结构)→ SimApiDataResponse + // data 由 SimApiDataResponse.serializeObject 内嵌为对象(ISerializable → 对象; + // HashMap 等动态结构 → SimApiJson 序列化后解析内嵌),不会变成 JSON 字符串 + ObjectResult(SimApiDataResponse(actionResult)).invoke(context) + } + } + + /// 根据 ModelState 错误构建 ValidationProblemDetails + private func createValidationProblemDetails(modelBindingContext: ActionBindingContext) { + let details = ValidationProblemDetails() + if (hasUnsupportedContentTypeError(modelBindingContext.modelState)) { + details.`type` = "https://tools.ietf.org/html/rfc9110#section-15.5.16" + details.title = "Unsupported Media Type" + details.status = 415 + } else { + details.`type` = "https://tools.ietf.org/html/rfc9110#section-15.5.1" + details.title = "One or more validation errors occurred." + details.status = 400 + for ((name, entry) in modelBindingContext.modelState) { + details.errors.add(name, entry.errors |> map {f => f.description} |> collectArray) + } + } + return details + } + + /// 检查 ModelState 中是否含有 UnsupportedContentTypeException 错误 + private func hasUnsupportedContentTypeError(modelState: ModelStateDictionary) { + for ((_, entry) in modelState) { + for (error in entry.errors) { + if (error.exception.flatMap {f => f as UnsupportedContentTypeException}.isSome()) { + return true + } + } + } + return false + } + + /// 通过 DI 容器实例化控制器,并注入当前 HttpContext + private func createControllerInstance(): Object { + let instance = ActivatorUtilities.createInstance(context.services, actionDescriptor.controllerType) + if (let controller: SimApiBaseController <- instance) { + controller.bindRequestContext(context) + } + return instance + } +} diff --git a/src/helpers/SimApiAesUtil.cj b/src/helpers/SimApiAesUtil.cj new file mode 100644 index 0000000..3c9210d --- /dev/null +++ b/src/helpers/SimApiAesUtil.cj @@ -0,0 +1,442 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.random.* +import stdx.crypto.digest.* +import stdx.encoding.base64.* + +/** + * AES-256-CBC + PKCS7 加解密工具(对齐 C# SimApiAesUtil)。 + * + * 约定(与 C# 完全一致): + * - 密钥:SHA256(key 字符串) → 32 字节 + * - 模式:AES-256-CBC,PKCS7 填充 + * - IV:每次加密随机生成 16 字节,前置在密文前 + * - 输出:Base64(IV(16) + 密文) + * + * 仓颉生态(stdx / soulsoft)均无现成 AES 实现,此处纯仓颉实现 FIPS-197 AES-256。 + */ +public class SimApiAesUtil { + private init() {} + + /** + * AES 加密:Base64(随机IV + 密文)。 + * @param plainText 明文。 + * @param key 字符串密钥(SHA256 处理后为 256 位)。 + * @return Base64(IV + 密文)。 + */ + public static func encrypt(plainText: String, key: String): String { + if (plainText.isEmpty()) { + throw Exception("plainText 不能为空") + } + if (key.isEmpty()) { + throw Exception("key 不能为空") + } + let keyBytes = processKey(key) + let iv = generateIv() + let padded = pkcs7Pad(plainText.toArray()) + let cipher = cbcEncrypt(padded, keyBytes, iv) + + // IV + 密文 → Base64 + var out = ArrayList() + for (b in iv) { + out.add(toB(b)) + } + for (b in cipher) { + out.add(toB(b)) + } + toBase64String(out.toArray()) + } + + /** + * AES 解密。 + * @param cipherText Base64(IV + 密文)。 + * @param key 字符串密钥(与加密时相同)。 + * @return 明文。 + */ + public static func decrypt(cipherText: String, key: String): String { + if (cipherText.isEmpty()) { + throw Exception("cipherText 不能为空") + } + if (key.isEmpty()) { + throw Exception("key 不能为空") + } + let all = fromBase64String(cipherText).getOrThrow { Exception("Base64 解码失败") } + if (all.size < 32) { + throw Exception("密文长度非法") + } + var iv = Array(16, repeat: 0) + var cipher = Array(all.size - 16, repeat: 0) + for (i in 0..16) { + iv[i] = toU8(all[i]) + } + for (i in 0..cipher.size) { + cipher[i] = toU8(all[i + 16]) + } + let keyBytes = processKey(key) + let padded = cbcDecrypt(cipher, keyBytes, iv) + let plain = pkcs7Unpad(padded) + String.fromUtf8(toBytes(plain)) + } + + // ===== 密钥与 IV ===== + + /// SHA256(key) → 32 字节密钥 + @OverflowWrapping + private static func processKey(key: String): Array { + let md = SHA256() + md.write(key.toArray()) + let digest = md.finish() + var out = Array(digest.size, repeat: 0) + for (i in 0..digest.size) { + out[i] = toU8(digest[i]) + } + out + } + + /// 16 字节随机 IV + private static func generateIv(): Array { + let rnd = Random() + var iv = Array(16, repeat: 0) + for (i in 0..16) { + iv[i] = UInt8(rnd.nextUInt64() & 0xFFu64) + } + iv + } + + // ===== PKCS7 填充 ===== + + @OverflowWrapping + private static func pkcs7Pad(data: Array): Array { + let padLen = 16 - (data.size % 16) + var out = ArrayList() + for (b in data) { + out.add(toU8(b)) + } + for (i in 0..padLen) { + out.add(UInt8(padLen)) + } + out.toArray() + } + + @OverflowWrapping + private static func pkcs7Unpad(data: Array): Array { + if (data.size == 0) { + return Array(0, repeat: 0) + } + let padLen = Int64(data[data.size - 1]) + if (padLen < 1 || padLen > 16) { + throw Exception("PKCS7 填充非法") + } + data[0..data.size - padLen] + } + + // ===== CBC 模式 ===== + + @OverflowWrapping + private static func cbcEncrypt(padded: Array, key: Array, iv: Array): Array { + let roundKeys = keyExpansion(key) + var prev = Array(16, repeat: 0) + for (i in 0..16) { + prev[i] = iv[i] + } + var out = ArrayList() + let n = padded.size / 16 + for (block in 0..n) { + var state = Array(16, repeat: 0) + for (i in 0..16) { + state[i] = padded[block * 16 + i] ^ prev[i] + } + let enc = aesEncryptBlock(state, roundKeys) + for (b in enc) { + out.add(b) + } + prev = enc + } + out.toArray() + } + + @OverflowWrapping + private static func cbcDecrypt(cipher: Array, key: Array, iv: Array): Array { + let roundKeys = keyExpansion(key) + var prev = Array(16, repeat: 0) + for (i in 0..16) { + prev[i] = iv[i] + } + var out = ArrayList() + let n = cipher.size / 16 + for (block in 0..n) { + var blockBytes = Array(16, repeat: 0) + for (i in 0..16) { + blockBytes[i] = cipher[block * 16 + i] + } + let dec = aesDecryptBlock(blockBytes, roundKeys) + for (i in 0..16) { + out.add(dec[i] ^ prev[i]) + } + prev = blockBytes + } + out.toArray() + } + + // ===== AES-256 核心(FIPS-197) ===== + + /// S-box / 逆 S-box(运行时生成,避免手写 256 字节表出错) + private static let sbox: Array = generateSbox() + private static let invSbox: Array = generateInvSbox() + + @OverflowWrapping + private static func generateSbox(): Array { + var s = Array(256, repeat: 0) + var p = 1u8 + var q = 1u8 + while (true) { + // p *= 3(GF(2^8) 生成元遍历) + p = p ^ (p << 1u8) ^ (if ((p & 0x80u8) != 0u8) { 0x1Bu8 } else { 0u8 }) + // q /= 3(等价乘以 0xF6) + q = q ^ (q << 1u8) + q = q ^ (q << 2u8) + q = q ^ (q << 4u8) + q = q ^ (if ((q & 0x80u8) != 0u8) { 0x09u8 } else { 0u8 }) + // 仿射变换 + let x = q ^ rotl8(q, 1) ^ rotl8(q, 2) ^ rotl8(q, 3) ^ rotl8(q, 4) + s[Int64(p)] = x ^ 0x63u8 + if (p == 1u8) { + break + } + } + // p 序列遍历非零元素,s[0] 从未赋值:AES 规定 S(0) = 0x63(0 的逆为 0,仿射变换结果) + s[0] = 0x63u8 + s + } + + @OverflowWrapping + private static func generateInvSbox(): Array { + var inv = Array(256, repeat: 0) + for (i in 0..256) { + inv[Int64(sbox[i])] = UInt8(i) + } + inv + } + + /// 8 位循环左移 + private static func rotl8(v: UInt8, n: Int64): UInt8 { + UInt8(((UInt16(v) << n) | (UInt16(v) >> (8 - n))) & 0xFFu16) + } + + /// GF(2^8) 乘以 2(xtime) + @OverflowWrapping + private static func xtime(a: UInt8): UInt8 { + if ((a & 0x80u8) != 0u8) { + (a << 1u8) ^ 0x1Bu8 + } else { + a << 1u8 + } + } + + /// GF(2^8) 通用乘法 + @OverflowWrapping + private static func gfMul(a: UInt8, b: UInt8): UInt8 { + var result = 0u8 + var aa = a + var bb = b + for (i in 0..8) { + if ((bb & 1u8) != 0u8) { + result = result ^ aa + } + let hi = (aa & 0x80u8) != 0u8 + aa = aa << 1u8 + if (hi) { + aa = aa ^ 0x1Bu8 + } + bb = bb >> 1u8 + } + result + } + + /// 密钥扩展:32 字节密钥 → 240 字节轮密钥(60 words,AES-256 共 15 轮) + @OverflowWrapping + private static func keyExpansion(key: Array): Array { + var w = Array(240, repeat: 0) + for (i in 0..32) { + w[i] = key[i] + } + var rcon = 1u8 + for (i in 8..60) { + var temp = Array(4, repeat: 0) + for (j in 0..4) { + temp[j] = w[(i - 1) * 4 + j] + } + if (i % 8 == 0) { + // RotWord + let t0 = temp[0] + temp[0] = temp[1] + temp[1] = temp[2] + temp[2] = temp[3] + temp[3] = t0 + // SubWord + for (j in 0..4) { + temp[j] = sbox[Int64(temp[j])] + } + temp[0] = temp[0] ^ rcon + rcon = xtime(rcon) + } else if (i % 8 == 4) { + for (j in 0..4) { + temp[j] = sbox[Int64(temp[j])] + } + } + for (j in 0..4) { + w[i * 4 + j] = w[(i - 8) * 4 + j] ^ temp[j] + } + } + w + } + + @OverflowWrapping + private static func aesEncryptBlock(input: Array, roundKeys: Array): Array { + var state = Array(16, repeat: 0) + for (i in 0..16) { + state[i] = input[i] + } + addRoundKey(state, roundKeys, 0) + for (round in 1..14) { + subBytes(state) + shiftRows(state) + mixColumns(state) + addRoundKey(state, roundKeys, round) + } + subBytes(state) + shiftRows(state) + addRoundKey(state, roundKeys, 14) + state + } + + @OverflowWrapping + private static func aesDecryptBlock(input: Array, roundKeys: Array): Array { + var state = Array(16, repeat: 0) + for (i in 0..16) { + state[i] = input[i] + } + addRoundKey(state, roundKeys, 14) + // 轮 13..1(逆序) + for (i in 0..13) { + let round = 13 - i + invShiftRows(state) + invSubBytes(state) + addRoundKey(state, roundKeys, round) + invMixColumns(state) + } + invShiftRows(state) + invSubBytes(state) + addRoundKey(state, roundKeys, 0) + state + } + + @OverflowWrapping + private static func addRoundKey(state: Array, roundKeys: Array, round: Int64): Unit { + for (i in 0..16) { + state[i] = state[i] ^ roundKeys[round * 16 + i] + } + } + + @OverflowWrapping + private static func subBytes(state: Array): Unit { + for (i in 0..16) { + state[i] = sbox[Int64(state[i])] + } + } + + @OverflowWrapping + private static func invSubBytes(state: Array): Unit { + for (i in 0..16) { + state[i] = invSbox[Int64(state[i])] + } + } + + /// ShiftRows:行 r 循环左移 r 字节(列主序 state[i] = s[r + 4*c]) + @OverflowWrapping + private static func shiftRows(state: Array): Unit { + var tmp = Array(16, repeat: 0) + for (r in 0..4) { + for (c in 0..4) { + tmp[r + 4 * c] = state[r + 4 * ((c + r) % 4)] + } + } + for (i in 0..16) { + state[i] = tmp[i] + } + } + + /// InvShiftRows:行 r 循环右移 r 字节 + @OverflowWrapping + private static func invShiftRows(state: Array): Unit { + var tmp = Array(16, repeat: 0) + for (r in 0..4) { + for (c in 0..4) { + tmp[r + 4 * c] = state[r + 4 * ((c - r % 4 + 4) % 4)] + } + } + for (i in 0..16) { + state[i] = tmp[i] + } + } + + /// MixColumns:每列乘固定矩阵 [[2,3,1,1],[1,2,3,1],[1,1,2,3],[3,1,1,2]] + @OverflowWrapping + private static func mixColumns(state: Array): Unit { + for (c in 0..4) { + let a0 = state[0 + 4 * c] + let a1 = state[1 + 4 * c] + let a2 = state[2 + 4 * c] + let a3 = state[3 + 4 * c] + state[0 + 4 * c] = gfMul(a0, 2u8) ^ gfMul(a1, 3u8) ^ a2 ^ a3 + state[1 + 4 * c] = a0 ^ gfMul(a1, 2u8) ^ gfMul(a2, 3u8) ^ a3 + state[2 + 4 * c] = a0 ^ a1 ^ gfMul(a2, 2u8) ^ gfMul(a3, 3u8) + state[3 + 4 * c] = gfMul(a0, 3u8) ^ a1 ^ a2 ^ gfMul(a3, 2u8) + } + } + + /// InvMixColumns:每列乘逆矩阵 [[14,11,13,9],[9,14,11,13],[13,9,14,11],[11,13,9,14]] + @OverflowWrapping + private static func invMixColumns(state: Array): Unit { + for (c in 0..4) { + let a0 = state[0 + 4 * c] + let a1 = state[1 + 4 * c] + let a2 = state[2 + 4 * c] + let a3 = state[3 + 4 * c] + state[0 + 4 * c] = gfMul(a0, 14u8) ^ gfMul(a1, 11u8) ^ gfMul(a2, 13u8) ^ gfMul(a3, 9u8) + state[1 + 4 * c] = gfMul(a0, 9u8) ^ gfMul(a1, 14u8) ^ gfMul(a2, 11u8) ^ gfMul(a3, 13u8) + state[2 + 4 * c] = gfMul(a0, 13u8) ^ gfMul(a1, 9u8) ^ gfMul(a2, 14u8) ^ gfMul(a3, 11u8) + state[3 + 4 * c] = gfMul(a0, 11u8) ^ gfMul(a1, 13u8) ^ gfMul(a2, 9u8) ^ gfMul(a3, 14u8) + } + } + + // ===== 字节转换 ===== + + /// Byte → UInt8:负 Byte(>127 字节的补码)按位重解释为 0-255 + private static func toU8(b: Byte): UInt8 { + if (b < 0) { + UInt8(Int64(b) + 256) + } else { + UInt8(Int64(b)) + } + } + + private static func toB(u: UInt8): Byte { + let b: Byte = u + b + } + + private static func toBytes(data: Array): Array { + var out = Array(data.size, repeat: 0) + for (i in 0..data.size) { + out[i] = toB(data[i]) + } + out + } +} diff --git a/src/helpers/SimApiAuth.cj b/src/helpers/SimApiAuth.cj new file mode 100644 index 0000000..6d9e237 --- /dev/null +++ b/src/helpers/SimApiAuth.cj @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.collection.concurrent.* +import std.convert.* +import stdx.encoding.json.* +import soulsoft_serialization.* +import redis.client.* +import simapi.communications.* +import simapi.configurations.* +import simapi.exceptions.* + +/** + * 认证助手:基于 Header Token 的登录态管理。 + * 支持两种存储模式: + * - Redis 模式:配置了 RedisConfiguration 时使用,支持多实例共享。 + * - InMemory 模式:未配置 Redis 时自动使用,重启后登录态丢失。 + */ +public class SimApiAuth { + private static let tokenCachePrefix = "SimApi:Auth:Token:" + private static let tokenSetCachePrefix = "SimApi:Auth:User:" + + private var _redis: ?RedisClient = None + private var _redisHost: String = "" + private var _redisPort: UInt16 = 6379 + + // InMemory 模式:token → 登录信息 JSON + private let _tokenStore = ConcurrentHashMap() + // InMemory 模式:userId → token 集合 + private let _userTokens = ConcurrentHashMap>() + + /** + * 创建认证助手(依赖注入 SimApiOptions)。 + * @param options SimApi 配置(redisConfiguration 非空时使用 Redis,否则 InMemory)。 + */ + public init(options: SimApiOptions) { + let redisConfiguration = options.redisConfiguration + if (!redisConfiguration.isEmpty()) { + let (host, port) = parseRedisConfig(redisConfiguration) + _redisHost = host + _redisPort = port + _redis = Some(RedisClient(host, port)) + } + } + + /** + * 登录:生成 token 并保存登录信息。 + * @param loginItem 登录信息。 + * @param expireSeconds 过期秒数,默认 7 天。 + * @param token 指定 token(可选,默认随机生成)。 + * @return 登录 token。 + */ + public func login(loginItem: SimApiLoginItem, expireSeconds!: Int64 = 604800, token!: String = ""): String { + let newToken = if (token.isEmpty()) { generateToken() } else { token } + let tokenKey = "${tokenCachePrefix}${newToken}" + let setKey = "${tokenSetCachePrefix}${loginItem._id}" + let json = loginItemJson(loginItem) + + if (let Some(redis) <- _redis) { + redis.set(tokenKey, Blob.fromUtf8(json), ex: Some(expireSeconds)) + redis.sadd(setKey, [Blob.fromUtf8(newToken)]) + redis.expire(setKey, expireSeconds) + } else { + _tokenStore[newToken] = json + var tokens = _userTokens.get(loginItem._id) + if (tokens == None) { + tokens = HashSet() + _userTokens[loginItem._id] = tokens.getOrThrow() + } + tokens.getOrThrow().add(newToken) + } + return newToken + } + + /** + * 更新登录信息(token 不变)。 + * @param loginItem 新的登录信息。 + * @param token 已有 token。 + * @return 原 token。 + */ + public func update(loginItem: SimApiLoginItem, token: String): String { + let tokenKey = "${tokenCachePrefix}${token}" + let json = loginItemJson(loginItem) + if (let Some(redis) <- _redis) { + redis.set(tokenKey, Blob.fromUtf8(json)) + } else { + _tokenStore[token] = json + } + return token + } + + /** + * 获取登录信息。 + * @param token 登录 token。 + * @return 登录信息;token 无效返回 None。 + */ + public func getLogin(token: String): ?SimApiLoginItem { + let tokenKey = "${tokenCachePrefix}${token}" + let json: ?String + if (let Some(redis) <- _redis) { + json = match (redis.get(tokenKey)) { + case Some(blob) => Some(blob.toUtf8()) + case None => None + } + } else { + json = _tokenStore.get(token) + } + return match (json) { + case Some(j) => Some(parseLoginItem(j)) + case None => None + } + } + + /** + * 获取某用户全部登录信息。 + * @param userId 用户 ID。 + * @return 登录信息数组。 + */ + public func getAllLogins(userId: String): Array { + let tokens = getTokensOfUser(userId) + var result = ArrayList() + for (token in tokens) { + if (let Some(item) <- getLogin(token)) { + result.add(item) + } else { + removeTokenOfUser(userId, token) + } + } + return result.toArray() + } + + /** + * 退出登录。 + * @param token 登录 token。 + */ + public func logout(token: String): Unit { + let item = getLogin(token) + if (let Some(item) <- item) { + removeTokenOfUser(item._id, token) + } + let tokenKey = "${tokenCachePrefix}${token}" + if (let Some(redis) <- _redis) { + redis.del([tokenKey]) + } else { + _tokenStore.remove(token) + } + } + + /** + * 退出某用户全部登录。 + * @param userId 用户 ID。 + */ + public func logoutAll(userId: String): Unit { + let tokens = getTokensOfUser(userId) + if (let Some(redis) <- _redis) { + for (token in tokens) { + redis.del(["${tokenCachePrefix}${token}"]) + } + redis.del(["${tokenSetCachePrefix}${userId}"]) + } else { + _userTokens.remove(userId) + for (token in tokens) { + _tokenStore.remove(token) + } + } + } + + private func getTokensOfUser(userId: String): ArrayList { + var result = ArrayList() + if (let Some(redis) <- _redis) { + let members = redis.smembers("${tokenSetCachePrefix}${userId}") + for (m in members) { + result.add(m.toUtf8()) + } + } else { + if (let Some(tokens) <- _userTokens.get(userId)) { + for (t in tokens) { + result.add(t) + } + } + } + return result + } + + private func removeTokenOfUser(userId: String, token: String): Unit { + if (let Some(redis) <- _redis) { + redis.srem("${tokenSetCachePrefix}${userId}", [Blob.fromUtf8(token)]) + } else { + if (let Some(tokens) <- _userTokens.get(userId)) { + tokens.remove(token) + } + } + } + + private static func generateToken(): String { + // 对齐 C#:token = Guid.NewGuid().ToString()(小写、8-4-4-4-12) + SimApiUtil.newGuid() + } + + private static func parseRedisConfig(config: String): (String, UInt16) { + let parts = config.split(":") + if (parts.size == 2) { + return (parts[0], UInt16.parse(parts[1])) + } + return (config, 6379u16) + } + + private static func loginItemJson(item: SimApiLoginItem): String { + // 统一 JSON 序列化:对齐 .NET JsonSerializer.Serialize(item) + JsonSerializer.serializeObject(item) + } + + private static func parseLoginItem(json: String): SimApiLoginItem { + // 统一 JSON 反序列化:对齐 .NET JsonSerializer.Deserialize(json) + JsonSerializer.deserializeObject(json) + } +} diff --git a/src/helpers/SimApiCache.cj b/src/helpers/SimApiCache.cj new file mode 100644 index 0000000..856d9e7 --- /dev/null +++ b/src/helpers/SimApiCache.cj @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.collection.concurrent.* +import std.convert.* +import redis.client.* +import simapi.configurations.* +import simapi.exceptions.* + +/** + * 缓存助手:Key 自动加前缀 "SimApi:Cache:"。 + * 存储后端与 SimApiAuth 一致:配置了 Redis 用 Redis,否则 InMemory。 + */ +public class SimApiCache { + private static let prefix = "SimApi:Cache:" + + private var _redis: ?RedisClient = None + private let _store = ConcurrentHashMap() + + /** + * 创建缓存(依赖注入 SimApiOptions)。 + * @param options SimApi 配置(redisConfiguration 非空时使用 Redis,否则 InMemory)。 + */ + public init(options: SimApiOptions) { + let redisConfiguration = options.redisConfiguration + if (!redisConfiguration.isEmpty()) { + let (host, port) = parseRedisConfig(redisConfiguration) + _redis = Some(RedisClient(host, port)) + } + } + + /** + * 设置缓存。 + * @param key 缓存键。 + * @param value 缓存值(不能为 null)。 + * @param expireSeconds 过期秒数(可选)。 + */ + public func set(key: String, value: String, expireSeconds!: Int64 = -1): Unit { + if (let Some(redis) <- _redis) { + if (expireSeconds > 0) { + redis.set("${prefix}${key}", Blob.fromUtf8(value), ex: Some(expireSeconds)) + } else { + redis.set("${prefix}${key}", Blob.fromUtf8(value)) + } + } else { + _store["${prefix}${key}"] = value + } + } + + /** + * 移除缓存。 + */ + public func remove(key: String): Unit { + if (let Some(redis) <- _redis) { + redis.del(["${prefix}${key}"]) + } else { + _store.remove("${prefix}${key}") + } + } + + /** + * 缓存 Key 是否存在。 + */ + public func hasKey(key: String): Bool { + get(key) != None + } + + /** + * 获取 string 类型缓存。 + */ + public func get(key: String): ?String { + if (let Some(redis) <- _redis) { + return match (redis.get("${prefix}${key}")) { + case Some(blob) => Some(blob.toUtf8()) + case None => None + } + } + return _store.get("${prefix}${key}") + } + + private static func parseRedisConfig(config: String): (String, UInt16) { + let parts = config.split(":") + if (parts.size == 2) { + return (parts[0], UInt16.parse(parts[1])) + } + return (config, 6379u16) + } +} diff --git a/src/helpers/SimApiControllerScanner.cj b/src/helpers/SimApiControllerScanner.cj new file mode 100644 index 0000000..17a216e --- /dev/null +++ b/src/helpers/SimApiControllerScanner.cj @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Helpers/SimApiControllerScanner:自动发现调用者包中的 MVC 控制器。 + * + * 对齐 C# 的控制器发现机制: + * - C# 通过 StackTrace 获取调用程序集,再 Assembly.GetTypes() 扫描所有类型 + * - 仓颉版通过 Error.getStackTrace() 获取调用者包名,再 PackageInfo 枚举类型, + * 过滤出继承 Controller 的类型(含子包) + */ + +package simapi.helpers + +import std.collection.* +import std.core.* +import std.reflect.* +import soulsoft_web_mvc.core.* + +/** + * 控制器自动扫描器:从调用栈定位调用者包,枚举该包(含子包)中继承 Controller 的类型。 + */ +public class SimApiControllerScanner { + private init() {} + + /** + * 扫描调用者包及其所有子包中的控制器类型。 + * @return 找到的控制器类型列表(不含抽象类型与 Controller 基类本身)。 + */ + public static func scan(): Array { + let callerPackage = getCallerPackage() + var result = ArrayList() + collectControllers(callerPackage, result) + result.toArray() + } + + /** + * 获取调用者(应用)包名:遍历栈帧,跳过 simapi/soulsoft/std 等框架包, + * 返回第一个应用包的 declaringClass(对齐 C# 通过 StackTrace 找调用程序集)。 + */ + public static func getCallerPackage(): String { + try { + throw Exception("probe") + } catch (ex: Exception) { + let st = ex.getStackTrace() + for (el in st) { + let name = el.declaringClass + // 跳过本类及框架包 + if (name.isEmpty()) { + continue + } + if (name.startsWith("simapi.") || name == "simapi") { + continue + } + if (name.startsWith("soulsoft_") || name.startsWith("std.") || name.startsWith("stdx.")) { + continue + } + return name + } + return "" + } + } + + /** + * 收集指定包及其子包中的控制器类型。 + */ + private static func collectControllers(packageName: String, result: ArrayList): Unit { + if (packageName.isEmpty()) { + return + } + try { + let info = PackageInfo.get(packageName) + for (ti in info.typeInfos) { + if (isController(ti)) { + result.add(ti) + } + } + // 递归扫描子包(subPackages 的 name 是短名,需拼接全限定名) + for (sub in info.subPackages) { + collectControllers("${packageName}.${sub.name}", result) + } + } catch (_: Exception) { + // 包不存在时跳过 + } + } + + /** + * 判断类型是否为控制器: + * - 继承 Controller(soulsoft_web_mvc.core.Controller) + * - 非抽象类 + * - 不是 Controller 基类本身 + */ + private static func isController(typeInfo: TypeInfo): Bool { + if (let classTypeInfo: ClassTypeInfo <- typeInfo) { + if (classTypeInfo.isAbstract()) { + return false + } + if (typeInfo == TypeInfo.of()) { + return false + } + return typeInfo.isSubtypeOf(TypeInfo.of()) + } + false + } +} diff --git a/src/helpers/SimApiError.cj b/src/helpers/SimApiError.cj new file mode 100644 index 0000000..916f46e --- /dev/null +++ b/src/helpers/SimApiError.cj @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import simapi.exceptions.* + +/** + * 错误抛出辅助类:所有业务错误统一通过这里抛出 SimApiException。 + * 对应 C# 的 SimApi.Helpers.SimApiError。 + */ +public class SimApiError { + private init() {} + + /** + * 直接抛错。 + * @param code 错误代码,默认 500。 + * @param message 错误描述,默认空(由 code 自动带取描述)。 + */ + public static func error(code!: Int64 = 500, message!: String = ""): Unit { + throw SimApiException(code, message: message) + } + + /** + * 条件为 true 时抛错。 + * @param condition 检测条件。 + * @param code 错误代码,默认 400。 + * @param message 错误描述。 + */ + public static func errorWhen(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit { + if (condition) { + error(code: code, message: message) + } + } + + /** + * 条件为 true 时抛错(别名)。 + */ + public static func errorWhenTrue(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit { + errorWhen(condition, code: code, message: message) + } + + /** + * 条件为 false 时抛错。 + */ + public static func errorWhenFalse(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit { + errorWhen(!condition, code: code, message: message) + } + + /** + * 给定的可选值为 None 时抛错。 + * @param condition 检测的可选值。 + * @param code 错误代码,默认 404。 + * @param message 错误描述。 + */ + public static func errorWhenNone(condition: ?Any, code!: Int64 = 404, message!: String = ""): Unit { + match (condition) { + case None => error(code: code, message: message) + case _ => () + } + } +} \ No newline at end of file diff --git a/src/helpers/SimApiHttpClient.cj b/src/helpers/SimApiHttpClient.cj new file mode 100644 index 0000000..7d36840 --- /dev/null +++ b/src/helpers/SimApiHttpClient.cj @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.io.* +import stdx.net.tls.* +import stdx.net.tls.common.* +import soulsoft_net_http.{HttpClient, HttpRequestMessage, JsonContent} +import soulsoft_net_http.{HttpMethod as NetHttpMethod} +import soulsoft_serialization.* +import simapi.communications.* +import simapi.configurations.* +import simapi.exceptions.* + +/** + * HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。 + * 对齐 C# 的 SimApi.Helpers.SimApiHttpClient: + * - 内部使用 soulsoft_net_http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient) + * - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String + * @param T 响应 data 的数据类型(需实现 ISerialization,如 SimApiLoginItem、String、Int64 等)。 + */ +public class SimApiHttpClient { + public var server: String + public var appId: String + public var appKey: String + public var signName: String = "sign" + public var timestampName: String = "timestamp" + public var nonceName: String = "nonce" + public var appIdName: ?String = Some("appId") + public var signFields: Array = [] + + public init(options!: SimApiOptions = SimApiOptions()) { + let httpOptions = options.simApiHttpClientOptions + server = httpOptions.server + appId = httpOptions.appId + appKey = httpOptions.appKey + signName = httpOptions.signName + timestampName = httpOptions.timestampName + nonceName = httpOptions.nonceName + appIdName = httpOptions.appIdName + signFields = httpOptions.signFields + } + + /** + * 发起签名请求(GET query 签名 + POST body)。 + * 对齐 C# SignQuery:query 串 = SignFields + AppId + timestamp + nonce,整体拼 AppKey 取 MD5 作为 sign。 + * @param url 请求路径(相对路径,自动拼接 server)。 + * @param body 请求体 JSON 字符串(可选)。 + * @param queries 额外查询参数(可选)。 + * @return 响应 data 字段反序列化后的 T。 + */ + public func signQuery(url: String, body!: String = "", queries!: HashMap = HashMap()): T where T <: ISerialization { + var queryUrl = StringBuilder() + for (field in signFields) { + queryUrl.append("${field}=") + if (let Some(v) <- queries.get(field)) { + queryUrl.append(v) + } + queryUrl.append("&") + } + if (let Some(name) <- appIdName) { + queryUrl.append("${name}=${appId}&") + } + let timestamp = Int64(SimApiUtil.timestampNow) + let nonce = generateNonce() + queryUrl.append("${timestampName}=${timestamp}&${nonceName}=${nonce}") + let signStr = "${queryUrl.toString()}&${appKey}" + var path = "${server}${url}?${queryUrl.toString()}&${signName}=${SimApiUtil.md5(signStr)}" + for ((k, v) in queries) { + if (!signFields.contains(k)) { + path = "${path}&${k}=${v}" + } + } + return query(path, body) + } + + /** + * 发起 AES 加密请求:body 加密后放入 {"data": "..."} 提交。 + * 对齐 C# AesQuery(SimApiOneFieldRequest { Data = Encrypt(body, AppKey) })。 + * @param url 请求路径(相对路径,自动拼接 server)。 + * @param body 请求体 JSON 字符串。 + * @return 响应 data 字段反序列化后的 T。 + */ + public func aesQuery(url: String, body: String): T where T <: ISerialization { + var target = "${server}${url}" + if (let Some(name) <- appIdName) { + target = "${target}?${name}=${appId}" + } + let encrypted = aesEncrypt(body) + let req = "{\"data\":\"${encrypted}\"}" + return query(target, req) + } + + /** + * 发起 AES 加密 + 签名请求。 + * 对齐 C# AesSignQuery。 + * @param url 请求路径(相对路径,自动拼接 server)。 + * @param body 请求体 JSON 字符串。 + * @param queries 额外查询参数(可选)。 + * @return 响应 data 字段反序列化后的 T。 + */ + public func aesSignQuery(url: String, body: String, queries!: HashMap = HashMap()): T where T <: ISerialization { + let encrypted = aesEncrypt(body) + let req = "{\"data\":\"${encrypted}\"}" + return signQuery(url, body: req, queries: queries) + } + + /** + * 发起 POST 请求并反序列化 SimApiBaseResponse,返回 data 字段。 + * 对齐 C# Query: + * ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync> → ErrorWhen(Code != 200) → return Data。 + * 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。 + */ + private func query(url: String, body: String): T where T <: ISerialization { + let client = HttpClient.create { builder => + builder.noProxy() + // 支持 https:配置 TLS(信任所有证书 + SNI 域名) + var tls = TlsClientConfig() + tls.verifyMode = CertificateVerifyMode.TrustAll + let host = extractHost(url) + if (!host.isEmpty()) { + tls.serverName = Some(host) + } + builder.tlsConfig(tls) + } + try { + let request = HttpRequestMessage(NetHttpMethod.Post, url) + request.content = JsonContent.create(body) + let response = client.send(request) + try { + SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode, message: "HTTP ERROR: ${response.statusCode}") + let result = response.content.readFromJson>() + SimApiError.errorWhen(result._code != 200, code: result._code, message: result._message) + return result._data.getOrThrow() + } finally { + response.close() + } + } finally { + client.close() + } + } + + private func aesEncrypt(plain: String): String { + // 对齐 C#:SimApiAesUtil.Encrypt(plain, AppKey)(AES-256-CBC + PKCS7,Base64(IV + 密文)) + SimApiAesUtil.encrypt(plain, appKey) + } + + private static func generateNonce(): String { + // 对齐 C#:nonce 直接用 Guid.NewGuid() + SimApiUtil.newGuid() + } + + /// 从完整 URL 提取 host(https://host[:port]/path → host)。 + private static func extractHost(fullUrl: String): String { + match (fullUrl.indexOf("://")) { + case Some(i) => + let rest = fullUrl[i + 3..] + let slash = rest.indexOf("/") ?? rest.size + let q = rest.indexOf("?") ?? rest.size + let end = if (slash < q) { slash } else { q } + return rest[0..end] + case None => return "" + } + } +} diff --git a/src/helpers/SimApiResponseFilter.cj b/src/helpers/SimApiResponseFilter.cj new file mode 100644 index 0000000..aa059af --- /dev/null +++ b/src/helpers/SimApiResponseFilter.cj @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import soulsoft_web_http.* +import simapi.communications.* + +/** + * 响应封装:对写操作(Unit)返回统一成功响应,对已有 SimApiBaseResponse 透传。 + * 在仓颉版中以中间件形式实现,对应 C# 的 SimApiResponseFilter。 + */ +public class SimApiResponseFilter { + public init() {} + + /** + * 包装响应委托:捕获下一级写入的响应内容。 + * 说明:仓颉版约定各路由处理器直接返回 SimApiBaseResponse, + * 由 SimApiExtensions 统一写入,本类保留供扩展使用。 + */ + public func wrap(next: RequestDelegate): RequestDelegate { + return { + context => + next(context) + if (!context.response.hasStarted) { + context.response.writeAsJson(SimApiBaseResponse()) + } + } + } +} diff --git a/src/helpers/SimApiUtil.cj b/src/helpers/SimApiUtil.cj new file mode 100644 index 0000000..69d88d7 --- /dev/null +++ b/src/helpers/SimApiUtil.cj @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.time.* +import std.random.* +import stdx.crypto.digest.* +import stdx.encoding.hex.* +import stdx.encoding.base64.* +import std.regex.* +import simapi.communications.* +import simapi.macros.* + +/** + * 工具类:对应 C# 的 SimApi.Helpers.SimApiUtil。 + * 提供时间、哈希、Base64、JSON、校验等常用能力。 + */ +public class SimApiUtil { + private init() {} + + /** + * 当前 CST 时间(UTC+8)。 + */ + public static prop cstNow: DateTime { + get() { + DateTime.nowUTC().addHours(8) + } + } + + /** + * 当前秒级 Unix 时间戳(Double)。 + */ + public static prop timestampNow: Float64 { + get() { + let ts = DateTime.nowUTC().toUnixTimeStamp() + Float64(ts / Duration.second) + } + } + + /** + * SimApi 包版本号(编译期从 simapi-cj/cjpm.toml 读取。 + * 宏在调用方项目编译时展开,cwd 为应用根,故用 ../simapi-cj 相对路径)。 + */ + @ReadTomlVersion[path: "../simapi-cj/cjpm.toml"] + public static let simApiVersion: String = "" + + /** + * 应用版本号(编译期从调用方项目 cjpm.toml 读取)。 + */ + @ReadTomlVersion[path: "cjpm.toml"] + public static let appVersion: String = "" + + /** + * MD5 加密字符串。 + * @param source 源字符串。 + * @return 32 位十六进制小写。 + */ + public static func md5(source: String): String { + let md = MD5() + md.write(source.toArray()) + toHexString(md.finish()) + } + + /** + * SHA1 加密字符串。 + * @param source 源字符串。 + * @return 40 位十六进制小写。 + */ + public static func sha1(source: String): String { + let sha = SHA1() + sha.write(source.toArray()) + toHexString(sha.finish()) + } + + /** + * SHA256 加密字符串。 + * @param source 源字符串。 + * @return 64 位十六进制小写。 + */ + public static func sha256(source: String): String { + let sha = SHA256() + sha.write(source.toArray()) + toHexString(sha.finish()) + } + + /** + * 字符串 Base64 编码。 + */ + public static func base64Encode(str: String): String { + toBase64String(str.toArray()) + } + + /** + * 从 Base64 解码字符串。 + */ + public static func base64Decode(base64Str: String): String { + let decoded = fromBase64String(base64Str).getOrThrow { Exception("Base64 解码失败") } + String.fromUtf8(decoded) + } + + /** + * 检测手机号是否正确(中国大陆 11 位手机号)。 + */ + public static func checkCell(cell: String): Bool { + Regex("^1[3456789]\\d{9}$").matches(cell) + } + + /** + * 判断是否是 Email 地址(简化校验)。 + */ + public static func checkEmail(email: String): Bool { + Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$").matches(email) + } + + /** + * 生成 UUID v4 字符串(对齐 C# Guid.NewGuid().ToString():小写、8-4-4-4-12 连字符格式)。 + * 仓颉标准库没有 GUID 生成器(stdx 的 GUID 是 stdx.net.http 内部类型), + * 此处用随机数自行构造:16 字节随机数 + 版本位(4)+ 变体位(10)。 + */ + public static func newGuid(): String { + let rnd = Random() + var bytes = Array(16, repeat: 0u8) + let h = rnd.nextUInt64() + let l = rnd.nextUInt64() + for (i in 0..8) { + bytes[i] = UInt8((h >> UInt64(i * 8)) & 0xFFu64) + bytes[8 + i] = UInt8((l >> UInt64(i * 8)) & 0xFFu64) + } + // UUID v4:版本位(第 7 字节高 4 位 = 4),变体位(第 9 字节高 2 位 = 10) + bytes[6] = (bytes[6] & 0x0Fu8) | 0x40u8 + bytes[8] = (bytes[8] & 0x3Fu8) | 0x80u8 + let hex = "0123456789abcdef" + var sb = StringBuilder() + for (i in 0..16) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + sb.append("-") + } + let b = bytes[i] + // 注意:String 索引返回 UInt8(字节),必须转 Rune 再 append,否则输出十进制 ASCII 码 + sb.append(Rune(UInt32(hex[Int64((b >> 4u8) & 0x0Fu8)]))) + sb.append(Rune(UInt32(hex[Int64(b & 0x0Fu8)]))) + } + sb.toString() + } + + /** + * 将对象序列化为 JSON 字符串(委托给 SimApiJson.json 统一实现,对齐 C# SimApiUtil.Json)。 + */ + public static func json(obj: ?Any): String { + SimApiJson.json(obj) + } +} diff --git a/src/interfaces/ISimApiAuthChecker.cj b/src/interfaces/ISimApiAuthChecker.cj new file mode 100644 index 0000000..a6e24bd --- /dev/null +++ b/src/interfaces/ISimApiAuthChecker.cj @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.interfaces + +import simapi.communications.* + +/** + * 认证后处理 Hook:实现后每次认证成功都会调用。 + * 对应 C# 的 SimApi.Interfaces.ISimApiAuthChecker。 + */ +public interface ISimApiAuthChecker { + /** + * 认证成功后执行。 + * @param loginItem 登录信息。 + * @param token 登录 token。 + */ + func run(loginItem: SimApiLoginItem, token: String): Unit +} diff --git a/src/logger/SimApiLogger.cj b/src/logger/SimApiLogger.cj new file mode 100644 index 0000000..6c5b2c0 --- /dev/null +++ b/src/logger/SimApiLogger.cj @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.logger + +import std.collection.concurrent.* +import std.env.* +import std.time.* +import soulsoft_extensions_logging.* + +/** + * SimApi 日志记录器:输出格式对齐 C# 原版 SimApiLogger。 + * 格式:`[ 分类 ][ 时间 ][ 级别 ]\n消息\n[异常]` + * 按日志级别着色输出。 + */ +public class SimApiLogger <: ILogger { + private let _name: String + + public init(name: String) { + _name = name + } + + public func log(logLevel: LogLevel, eventId: EventId, state: TState, exception: ?Exception, + formatter: (TState, ?Exception) -> String): Unit where TState <: ToString { + if (!isEnabled(logLevel)) { + return + } + + let now = DateTime.now() + let timeStr = formatTime(now) + let levelStr = levelName(logLevel) + + var sb = StringBuilder() + sb.append("${color(levelColor(logLevel))}[ ${_name} ][ ${timeStr} ][ ${levelStr} ]\n") + sb.append("${state}\n") + if (let Some(ex) <- exception) { + sb.append("${ex}\n") + } + sb.append(resetColor()) + // 对齐 C# Console.WriteLine:message 末尾 \n 之后再补一个 \n,形成空行分隔 + sb.append("\n") + + let writer = getStdOut() + writer.write(sb.toString()) + writer.flush() + } + + public func isEnabled(logLevel: LogLevel): Bool { + logLevel != LogLevel.Off + } + + /** + * 级别名称,对齐 C# 的 LogLevel.ToString()。 + */ + private static func levelName(logLevel: LogLevel): String { + match (logLevel) { + case LogLevel.Trace => "Trace" + case LogLevel.Debug => "Debug" + case LogLevel.Info => "Information" + case LogLevel.Warn => "Warning" + case LogLevel.Error => "Error" + case LogLevel.Fatal => "Critical" + case _ => "None" + } + } + + /** + * 级别对应 ANSI 前景色(对齐 C# ConsoleColor): + * Debug→DarkMagenta, Info→DarkCyan, Warn→Yellow, Error→Red, Fatal→DarkRed, 其他→White + */ + private static func levelColor(logLevel: LogLevel): String { + match (logLevel) { + case LogLevel.Debug => "35" // DarkMagenta + case LogLevel.Info => "36" // DarkCyan + case LogLevel.Warn => "33" // Yellow + case LogLevel.Error => "31" // Red + case LogLevel.Fatal => "31;1" // DarkRed(粗体近似) + case _ => "37" // White + } + } + + private static func color(code: String): String { + "\u{1b}[${code}m" + } + + private static func resetColor(): String { + "\u{1b}[0m" + } + + private static func formatTime(dt: DateTime): String { + let year = dt.year + let month = pad2(dt.monthValue) + let day = pad2(dt.dayOfMonth) + let hour = pad2(dt.hour) + let minute = pad2(dt.minute) + let second = pad2(dt.second) + let millis = pad3(dt.nanosecond / 1000000) + "${year}-${month}-${day} ${hour}:${minute}:${second}:${millis}" + } + + private static func pad2(v: Int64): String { + if (v < 10) { + return "0${v}" + } + "${v}" + } + + private static func pad3(v: Int64): String { + if (v < 10) { + return "00${v}" + } + if (v < 100) { + return "0${v}" + } + "${v}" + } +} + +/** + * SimApi 日志提供程序:按分类缓存日志记录器。 + */ +public class SimApiLoggerProvider <: ILoggerProvider { + private let _loggers = ConcurrentHashMap() + + public init() {} + + public func createLogger(categoryName: String): ILogger { + if (let Some(logger) <- _loggers.get(categoryName)) { + return logger + } + return _loggers.entryView(categoryName) { + view => if (view.value.isNone()) { + view.value = SimApiLogger(categoryName) + } + }.getOrThrow() + } + + public func close(): Unit {} + + public func isClosed(): Bool { + false + } +} diff --git a/src/macros/ReadTomlVersionMacro.cj b/src/macros/ReadTomlVersionMacro.cj new file mode 100644 index 0000000..4eb300a --- /dev/null +++ b/src/macros/ReadTomlVersionMacro.cj @@ -0,0 +1,95 @@ +macro package simapi.macros + +import std.ast.* +import std.fs.* +import std.collection.* + +/** + * 编译期宏:从指定 cjpm.toml 读取 version 字段并替换变量声明。 + * + * 用法: + * ``` + * @ReadTomlVersion[path: "cjpm.toml"] // 读取当前包(编译目录)的版本 + * let appVersion: String = "" + * @ReadTomlVersion[path: "../simapi-cj/cjpm.toml"] // 读取父包版本 + * let simApiVersion: String = "" + * ``` + * + * 说明:宏在编译期执行,工作目录为 cjpm build 的运行目录(应用项目根)。 + * 版本号仅匹配 `version = "x.y.z"`(排除 cjc-version)。 + */ +public macro ReadTomlVersion(attr: Tokens, input: Tokens): Tokens { + var path = "cjpm.toml" + try { + let attrText = attr.toString() + if (let Some(idx) <- attrText.indexOf("path")) { + let rest = attrText[idx..] + if (let Some(q1) <- rest.indexOf("\"")) { + let after = rest[(q1 + 1)..] + if (let Some(q2) <- after.indexOf("\"")) { + path = after[0..q2] + } + } + } + } catch (_: Exception) { + } + + var version = "0.0.0" + try { + let f = File(path, Read) + var buf = Array(8192, repeat: 0) + var bytes = ArrayList() + var n = f.read(buf) + while (n > 0) { + for (i in 0..n) { + bytes.add(buf[i]) + } + n = f.read(buf) + } + f.close() + // 按行解析(version 行是纯 ASCII;避免整体 UTF-8 解码含中文的 description 失败) + let data = bytes.toArray() + var lineStart = 0 + for (i in 0..data.size) { + let isLast = (i == data.size - 1) + if (data[i] == 10 || isLast) { + let end = if (data[i] == 10) { i } else { i + 1 } + let lineBytes = data[lineStart..end] + let lineText = String.fromUtf8(lineBytes) + if (!lineText.contains("cjc-version") && lineText.contains("version")) { + let parts = lineText.split("\"") + if (parts.size >= 2) { + version = parts[1] + } + } + lineStart = i + 1 + } + } + } catch (ex: Exception) { + version = "error:${ex.message}" + } + + // 生成替换声明:保留原声明的修饰符与变量名,只把值替换为版本号 + // declPrefix 例:"public static let" / "private let" / "let" + var declPrefix = "let" + var varName = "v" + try { + let inputText = input.toString() + if (let Some(letIdx) <- inputText.indexOf("let")) { + let prefix = inputText[0..letIdx].trimEnd() + if (!prefix.isEmpty()) { + declPrefix = "${prefix} let" + } + let rest = inputText[letIdx..] + let afterLet = rest[3..].trimStart() + if (let Some(nameEnd) <- afterLet.indexOf(":")) { + varName = afterLet[0..nameEnd].trimEnd() + } + } + } catch (_: Exception) { + } + + // 用 cangjieLex 把 "public static let name: String = "version"" 整体解析为 token 流 + let declText = "${declPrefix} ${varName}: String = \"${version}\"" + cangjieLex(declText) +} diff --git a/src/middlewares/SimApiAuthMiddleware.cj b/src/middlewares/SimApiAuthMiddleware.cj new file mode 100644 index 0000000..d1146c6 --- /dev/null +++ b/src/middlewares/SimApiAuthMiddleware.cj @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.middlewares + +import soulsoft_web_http.* +import simapi.communications.* +import simapi.helpers.* + +/** + * 认证信息获取中间件:从 Header Token 或 Query token 解析登录信息并注入上下文。 + * 对应 C# 的 SimApi.Middlewares.SimApiAuthMiddleware。 + */ +public class SimApiAuthMiddleware <: IMiddleware { + private let _auth: SimApiAuth + + public init(auth: SimApiAuth) { + _auth = auth + } + + /** + * 中间件入口(IMiddleware 风格,对齐 C# UseMiddleware 的 InvokeAsync)。 + */ + public func invoke(context: HttpContext, next: RequestDelegate): Unit { + var token = context.request.headers.get("Token") + if (token == None || token == Some("")) { + token = context.request.query.get("token") + } + if (let Some(token) <- token) { + if (!token.isEmpty()) { + if (let Some(login) <- _auth.getLogin(token)) { + context.items["LoginToken"] = token + context.items["LoginInfo"] = login + } + } + } + next(context) + } +} diff --git a/src/middlewares/SimApiExceptionMiddleware.cj b/src/middlewares/SimApiExceptionMiddleware.cj new file mode 100644 index 0000000..faf8bc0 --- /dev/null +++ b/src/middlewares/SimApiExceptionMiddleware.cj @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.middlewares + +import soulsoft_web_http.* +import soulsoft_extensions_logging.* +import simapi.communications.* +import simapi.exceptions.* +import simapi.configurations.* + +/** + * 异常处理中间件:全异常捕获,统一输出 HTTP 200 + JSON 响应。 + * 对应 C# 的 SimApi.Middlewares.SimApiExceptionMiddleware。 + */ +public class SimApiExceptionMiddleware <: IMiddleware { + private let _options: SimApiOptions + private let _logger: ILogger + + public init(options: SimApiOptions, loggerFactory: ILoggerFactory) { + _options = options + _logger = loggerFactory.createLogger("SimApi.ExceptionMiddleware") + } + + /** + * 中间件入口(IMiddleware 风格,对齐 C# UseMiddleware 的 InvokeAsync)。 + */ + public func invoke(context: HttpContext, next: RequestDelegate): Unit { + try { + // 透传 Query-Id 请求头 + if (let Some(queryId) <- context.request.headers.get("Query-Id")) { + context.response.headers.add("Query-Id", queryId) + } + next(context) + // 若响应未开始且状态码不在跳过列表,则视为业务错误 + if (!context.response.hasStarted) { + let code = Int64(context.response.statusCode) + if (!_options.simApiExceptionOptions.skipStatusCodes.contains(code)) { + throw SimApiException(code) + } + } + } catch (ex: Exception) { + let response = buildResponse(ex, context) + if (!context.response.hasStarted) { + context.response.statusCode = 200 + context.response.contentType = "application/json; charset=utf-8" + context.response.write(responseJson(response)) + } + } + } + + private func buildResponse(ex: Exception, context: HttpContext): SimApiBaseResponse { + if (let simEx: SimApiException <- ex) { + let message = if (simEx.message.isEmpty()) { + SimApiBaseResponse.getDefaultMessage(simEx.code) + } else { + simEx.message + } + var response = SimApiBaseResponse(simEx.code, message) + if (context.response.statusCode == 404) { + response.message = "接口不存在" + } + return response + } + _logger.error(EventId(0), ex, ex.message) + return SimApiBaseResponse(500) + } + + private func responseJson(response: SimApiBaseResponse): String { + response.toJsonString() + } +} diff --git a/src/middlewares/SimApiRequestLogMiddleware.cj b/src/middlewares/SimApiRequestLogMiddleware.cj new file mode 100644 index 0000000..47e4431 --- /dev/null +++ b/src/middlewares/SimApiRequestLogMiddleware.cj @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.middlewares + +import std.collection.* +import std.io.* +import std.time.* +import soulsoft_web_http.* +import soulsoft_extensions_logging.* +import simapi.communications.* +import simapi.configurations.* + +/** + * 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码与耗时。 + * 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。 + */ +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 风格,对齐 C# UseMiddleware 的 InvokeAsync)。 + */ + public func invoke(context: HttpContext, next: RequestDelegate): Unit { + let start = MonoTime.now() + let fullUrl = context.request.getDisplayUrl() + var sb = StringBuilder() + + sb.append("[${context.request.method}] ${fullUrl}\n") + + // 请求头 + if (_options.simApiRequestLogOptions.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)) + + // 调用下一级 + next(context) + + // 响应信息 + let elapsed = MonoTime.now() - start + let elapsedMs = elapsed / Duration.millisecond + sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms\n") + + _logger.info(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("\"${SimApiJson.escapeJson(name)}\":\"${SimApiJson.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() + // 重置流位置,供后续业务读取 + if (let seekable: Seekable <- context.request.body) { + seekable.seek(SeekPosition.Begin(0)) + } + return truncateBody(bodyText) + } catch (_: Exception) { + return "(读取请求体失败)\n" + } + } + + private func truncateBody(body: String): String { + if (_options.simApiRequestLogOptions.requestStringLogLength <= 0 || + body.size <= _options.simApiRequestLogOptions.requestStringLogLength) { + return body + "\n" + } + // 简单按长度截断(不做 JSON 字段级截断,保持实现简洁) + let chars = body.toArray() + var sb = StringBuilder() + for (i in 0.._options.simApiRequestLogOptions.requestStringLogLength) { + sb.append(chars[i]) + } + return "${sb.toString()}...(${body.size})\n" + } +} diff --git a/src/simapi.cj b/src/simapi.cj new file mode 100644 index 0000000..bf929b7 --- /dev/null +++ b/src/simapi.cj @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi + +import simapi.communications.* +import simapi.configurations.* +import simapi.exceptions.* +import simapi.extensions.* +import simapi.helpers.* +import simapi.interfaces.* +import simapi.macros.* +import simapi.middlewares.* + +/** + * SimApi 仓颉版:ASP.NET Core 风格 API 基础框架。 + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net)。 + */ +public class SimApi { + private init() {} + + /** + * 包版本号(编译期从 simapi-cj/cjpm.toml 读取)。 + */ + @ReadTomlVersion[path: "../simapi-cj/cjpm.toml"] + public static let version: String = "" +}