Compare commits

...
41 Commits
Author SHA1 Message Date
xrain 829f009229 README: 引入示例版本号改为 1.0.3 2026-08-18 04:57:25 +08:00
xrain 57b781a316 README: 新增引入章节(中央仓 + Git 两种方式), 包名更新为 simcu::simapi / simcu::serialization 2026-08-18 04:52:31 +08:00
xrain b9ea4008c7 适配 serialization 包名迁移: import simapi_serialization → simcu::serialization, 依赖键 simcu::serialization 2026-08-18 04:26:36 +08:00
xrain a1acb99e08 包名迁移: simapi → simcu::simapi(发布中央仓用 organization=simcu) 2026-08-18 04:23:07 +08:00
xrain f88eee8d64 ISimApiAuthChecker 改为按接口注册 + getAll<T>() 解析,移除 SimApiOptions.authCheckers 手动列表 2026-08-18 03:19:27 +08:00
xrain 1ce85a68ab 鉴权顺序对齐 .NET: 先执行 ISimApiAuthChecker 再做类型权限判断 2026-08-18 03:01:17 +08:00
xrain cf19f15569 请求日志: 响应结构体末尾不再多加换行 2026-08-18 02:57:03 +08:00
xrain 3b252e08d5 请求日志: 响应行显示字节长度 [ N bytes ] 并换行输出响应体 2026-08-18 02:50:04 +08:00
xrain 8a5c34756b 请求日志: 显示响应体 + 新增 ShowFullUrl/ShowRunTime 配置(默认true) 2026-08-18 02:43:11 +08:00
xrain 3925d0d5ad fix: 无效 Token 导致的 /config 500——getLogin 解析失败返回 None 而非抛异常
- SimApiAuth.getLogin:JSON 无效/非对象时返回 None(视为 token 无效),
  不再抛 JsonException(Fail to convert to JsonObject)
- 新增 parseLoginItemSafe 私有辅助,删除旧 parseLoginItem
- 复现:带无效 Token 的 POST /config 之前 500,现在 200 正常
- 验证:无效/有效 Token、GET/POST /config 均正常,日志无 Fail to convert
2026-08-18 02:26:58 +08:00
xrain 11360b3359 docs: 更新 README——注解(@SimApiSign/@AesBody)、stdx.http、simapi_serialization 依赖、字段无下划线
- annotations 子包(原 attributes)
- @SimApiSign / @AesBody 声明式注解(替代手动调用)
- SimApiSignProviderBase / AesBodyProviderBase 移至 interfaces
- SimApiHttpClient/SimApiStorage/verifySign 基于 stdx.net.http(不依赖 soulsoft_net_http)
- SimApiUtil.json/escapeJson 直接实现、fromJson 免约束
- 依赖表:去掉 soulsoft_serialization/soulsoft_net_http,加 simapi_serialization
2026-08-18 02:08:00 +08:00
xrain aa2af7aee1 refactor: 移除 soulsoft_serialization / soulsoft_net_http 依赖——全部 HTTP 改 stdx.net.http
- SimApiStorage 4 处 S3 请求(PUT 上传/HEAD 检桶/PUT 建桶/POST 删除)改 stdx ClientBuilder/HttpRequestBuilder
- SimApiAuthCenter.verifySign 改 stdx(post + body + 读响应)
- cjpm.toml 删除 soulsoft_serialization、soulsoft_net_http(soulsoft web 栈传递依赖提供序列化)
- S3 全链路实测通过(上传/预签名/删除)
2026-08-18 01:56:32 +08:00
xrain 5c30f201ad refactor: SimApiHttpClient 改用 stdx.net.http——不再依赖 soulsoft_net_http
- query<T> 用 stdx.net.http ClientBuilder/HttpRequestBuilder/Client.send
  (等价 .NET HttpClient,替代 soulsoft_net_http.HttpClient)
- TLS 配置用 stdx TlsClientConfig(TrustAll + SNI 域名)
- 响应读取:status(UInt16) 2xx 判断 + InputStream 读 body
- 去掉 import soulsoft_net_http
- 端到端验证:stdx client 请求本机服务读响应正常
2026-08-18 01:29:02 +08:00
xrain 9c2e0ae0e3 refactor: AES 请求体改用 SimApiOneFieldRequest 序列化,去掉手拼字符串
- aesQuery/aesSignQuery:{"data":"..."} 手拼 → SimApiOneFieldRequest<String>
  经 SimApiUtil.json 序列化(对齐 C# new SimApiOneFieldRequest<string>{...})
- SimApiOneFieldRequest<T> 恢复带参构造(构造请求用)
2026-08-18 01:24:11 +08:00
xrain 79a997c014 refactor: 删除 SimApiBaseRequest 空类 + Request DTO 去掉显式构造器
- SimApiBaseRequest 空类无意义:.NET 无此类、仓颉版无继承无使用,删除
- SimApiIdOnlyRequest/SimApiStringIdOnlyRequest/SimApiOneFieldRequest/
  SimApiBasePageRequest 去掉所有显式构造器(含带参)——它们只作反序列化
  目标,无参构造由编译器自动提供,带参构造无人调用
2026-08-18 01:18:12 +08:00
xrain c7099040fa refactor: DTO 字段去下划线 + SimApiBaseResponse 不再可继承 + 删除 SimApiJson/toJsonString
- 纯 POCO DTO 字段去下划线:AuthDto(id/name/applicationId/profileId/scene 等)、
  SimApiLoginItem(id/types/meta/extra)、AesBodyRequest(data)、
  SimApiBaseResponse 系列(code/message/data/list/page/count/total)
- SimApiBaseResponse 去 open(不再允许继承)+ 删 @SerializerParent(无子类继承)
- SimApiResponse<T> / SimApiDataResponse 改为组合(自带 code/message/data)
- 删除 toJsonString(无使用点,统一走 simapi_serialization)
- 删除 SimApiJson.cj:json() → SimApiUtil.json() 直接序列化;
  escapeJson() 迁入 SimApiUtil
- 同步更新所有字段引用(AuthCenter/AuthIam/HttpClient/ExceptionMiddleware 等)
- 保留 sqlsharp 实体 User/Bot 的 _ 前缀(soulsoft 宏依赖)
2026-08-18 01:15:15 +08:00
xrain ea31bd9b6a refactor: 删除仅有空构造器的 public init() {}——编译器自动提供无参构造
删除(类无其他构造器):
- SimApiBaseModel / SimApiLoggerProvider / SimApiSignProviderBase / AesBodyProviderBase
- SimApiResponseFilter / SimApiAuthCenterOptions / SimApiExceptionOptions
- SimApiHttpClientOptions / SimApiOptions / SimApiSynapseOptions / SimApiRouteOptions
- SimApiStorageOptions / SimApiRequestLogOptions / SimApiJobServer / SimApiAuthOption
- AuthDto 5 个纯 POCO(AppAndProfileItem/ConfirmResponse/LoginInfoResponse/
  GroupRelatedItem/GroupDetailTreeNode)/ AesBodyRequest

保留(有带参构造器,空 init 是反序列化/绑定必需):
- GetCodeResponse / PermissionItem / SimApiBaseRequest 系列 / PageResponse
- SimApiLoginItem / SimApiDocGroup / SimApiStorage 相关 / 各 DTO
2026-08-18 00:59:48 +08:00
xrain 6e6b21ff83 refactor: SimApiSignProviderBase / AesBodyProviderBase 移至 interfaces 子包
- 新增 src/interfaces/SimApiSignProviderBase.cj / AesBodyProviderBase.cj(package simapi.interfaces)
- SimApiSignChecker / SimApiAesBodyChecker 移除基类定义,import simapi.interfaces.*
- SimApiRequestDelegateFactory 已 import interfaces,无需改
- 说明:基类含配置字段故用 open class(Cangjie 接口不能声明字段)
2026-08-18 00:51:45 +08:00
xrain ef9765de5d feat: 新增 @SimApiSign 与 @AesBody 注解(对齐 C# SimApiSignAttribute / AesBodyAttribute)
- src/annotations/SimApiSign.cj:方法/类级声明式验签注解,keyProvider 用类型名(DI 解析)
- src/annotations/AesBody.cj:参数级声明式 AES body 解密注解
- SimApiRequestDelegateFactory 自动执行:
  - checkSimApiSign:解析 @SimApiSign → DI 取 SimApiSignProviderBase → SimApiSignChecker.verify
  - bindAesBody:@AesBody 参数 → DI 取 AesBodyProviderBase → decryptBody → 按类型反序列化
- 注解未指定类型名时用默认空实现(未配置即报错,对齐 C# 行为)
2026-08-18 00:35:59 +08:00
xrain d76b08a542 refactor: 子包 attributes 改名 annotations——simapi.attributes → simapi.annotations
- src/attributes/ → src/annotations/(OriginResponse.cj / SimApiAuth.cj)
- SimApiRequestDelegateFactory import 同步更新
2026-08-18 00:31:36 +08:00
xrain e9978ea5e0 feat: 自定义 FromBody 绑定——simapi_serialization 按运行时类型反序列化,DTO 免 @Serialization 宏
- SimApiRequestDelegateFactory:无注解参数(FromBody)用 JsonSerializer.Deserialize(typeInfo, body)
  反序列化(免宏、免约束),显式注解参数(Query/Form/Route/Header/Services)委托 soulsoft binder
- 请求体只读一次并缓存到 context.items["SimApi:BodyCache"](body 流不可重读,
  请求日志中间件与 FromBody 共用缓存)
- 验证:/auth/login/admin 的 LoginAdminRequest(无宏)反序列化成功,走到业务密码校验
2026-08-18 00:27:32 +08:00
xrain 73f60f6f15 refactor: 全框架 JSON 操作迁移到 simapi_serialization
- 依赖:新增 simapi_serialization path 依赖(保留 soulsoft_serialization 供 soulsoft web 栈传递使用)
- SimApiJson.json / SimApiUtil.json → JsonSerializer.Serialize
- SimApiUtil.fromJson / base64DecodeTo → JsonSerializer.Deserialize(去 ISerialization 约束)
- SimApiHttpClient signQuery/aesQuery/aesSignQuery/query → 去约束,反序列化换新库
- SimApiBaseResponse/SimApiDataResponse/SimApiLoginItem/AesBodyRequest/AuthSDK DTO → 去 @Serialization 宏/去 soulsoft 接口,改纯 POCO(simapi_serialization 反射)
- SimApiBaseResponse 标 @SerializerParent:子类响应序列化包含 _code/_message
- SimApiResultWriter / SimApiExceptionMiddleware → 响应输出走 simapi_serialization(统一 _code/_message 格式)
- SimApiRequestLogMiddleware 截断 → Deserialize<HashMap<String,Any>> + 重序列化
- SimApiAuthDto JsonValue 字段 → HashMap<String,Any>;SimApiCache.get<T> 去约束
- webdemo:AuthController readFromJson 换 Deserialize;cjpm.toml 补依赖
- 验证:webdemo 启动正常,GET/POST 接口响应格式统一,S3 存储全链路 OK
2026-08-18 00:07:00 +08:00
xrain 8b0a8f783c refactor: SimApiExtensions 移入根包 simapi 并改为静态类,删除 extensions 子包
- src/extensions/SimApiExtensions.cj 删除,内容合并进 src/SimApiExtensions.cj(package simapi)
- 接口+extend 改为静态类 SimApiExtensions:
  SimApiExtensions.addSimApi(builder, configure) / useSimApi(host)(对齐 .NET 根命名空间静态类)
- 私有辅助 addSimApiCore / addControllers 收敛为私有静态方法
- 用法从 builder.addSimApi{} / host.useSimApi() 改为 SimApiExtensions.addSimApi(builder){} / useSimApi(host)
2026-08-17 09:16:42 +08:00
xrain bcac7b1102 refactor: SimApiRequestDelegateFactory/SimApiResultWriter 从 extensions 移到 helpers
- 包 simapi.extensions → simapi.helpers
- 新增 interfaces/IBindRequestContext 接口切断 helpers↔controllers 循环依赖
  (工厂只依赖接口,不再依赖 SimApiBaseController)
- SimApiAuth 注解类与 helpers.SimApiAuth 同名冲突,用 import 别名 SimApiAuthAttribute 解决
2026-08-17 09:12:47 +08:00
xrain ff41d287ed refactor: 根包锚点 simapi.cj 改名为 SimApiExtensions.cj(对齐 .NET 根目录 SimApiExtensions.cs) 2026-08-17 09:10:02 +08:00
xrain 2b8d2c47be feat: SimApiError 增加顶层函数,支持无前缀 error(400);精简 simapi.cj 为包锚点
- error/errorWhen/errorWhenTrue/errorWhenFalse/errorWhenNull 提升为 simapi.helpers 顶层函数,
  import simapi.helpers.* 后可直接调用(对齐 C# using static SimApi.Helpers.SimApiError)
- SimApiError 类保留为兼容门面(旧写法仍可用)
- simapi.cj 精简为仅 package 声明:实测 cjpm 要求 src 根目录必须有 .cj 锚点,
  否则不扫描子目录导致整包编译为空;SimApi 类/version 常量无任何引用,已移除
2026-08-17 09:06:17 +08:00
xrain 67f7c16a3f feat: SimApiStorage 增加 deleteFiles 批量删除(S3 DeleteObjects)
- POST /{bucket}?delete + XML 一次删除多个对象(Minio RemoveObjects 同款接口)
- MinIO 强制要求 Content-Md5 头(缺失返回 MissingContentMD5),MD5 经 stdx 计算 + Base64
- 已用 oss.anymemory.cn 实测:批量删除后访问对象返回 404
2026-08-17 00:22:08 +08:00
xrain b4e808e775 fix: SimApiStorage 直接请求补 x-amz-date 签名头,Content-Type 移到 HttpContent
- 直接请求(上传/桶检测/建桶)需带 x-amz-date 请求头并纳入签名(缺省会 400)
- Content-Type 按 soulsoft_net_http 规则设在 ByteArrayContent.headers(不是 request.headers)
- 已用 oss.anymemory.cn 实测:上传/预签名上传/预签名下载回读全部一致
2026-08-17 00:08:50 +08:00
xrain eb488bcd5a feat: 实现 SimApiStorage(S3/MinIO 存储)
- 自实现 AWS Signature V4(HMAC-SHA256 基于 stdx SHA256),无需 Minio SDK
- getUploadUrl / getDownloadUrl(预签名 PUT/GET)
- uploadFile(直接 PUT 上传)、fullUrl / getUrl / getPath
- 构造时检测并自动创建桶(静态守卫只执行一次)
- 注册为 Scoped 以注入 IHttpContextAccessor(soulsoft DI 禁止 singleton 消费 scoped)
- 签名核心已用 AWS 官方测试向量验证(最终签名 5d672d79... 一致)
- README 同步(未实现表移除、新增 5.3 存储章节)
2026-08-17 00:00:15 +08:00
xrain 92eea3108a refactor: 对齐 simapi-net 的 20 项差异
- SimApiLoginItem._types 默认 [user]
- 新增 SimApiIdOnlyRequest
- errorWhenNone 改名 errorWhenNull
- SimApiAuth.update 保留 TTL(Redis ttl/expire)
- SimApiCache: set 接受 Any + 新增 get<T>/getString
- SimApiHttpClient 签名字段移出 Options
- SimApiUtil: md5/sha1 支持 mode + 新增 paginate
- @SimApiAuth 支持逗号分隔多类型
- Logger: 4 位毫秒 + IsEnabled 恒 true
- 删除 /versions 端点;exceptionHandler 改为抛错
- UseSimApi 中间件顺序对齐 .NET
- README 同步更新
2026-08-16 23:44:52 +08:00
xrain 40e0726087 docs: README 更新(新功能文档 + 修正过时状态)
- 新增:@SimApiAuth/@OriginResponse/验签/AES body/AES/BaseModel/AuthSDK 使用文档
- 修正:AuthGate/AES 已实现、自动控制器扫描、DI 构造示例、移除 ApiResult 引用
- cjpm.lock:新增 soulsoft_net_http 等依赖
2026-08-16 22:47:05 +08:00
xrain e278753a8c feat: SimApiOptions 增加 authCheckers 注册表
存储 ISimApiAuthChecker 类型列表(由 addSimApi 自动扫描填充),
供 @SimApiAuth 鉴权时逐个从 DI 解析执行
2026-08-16 22:47:05 +08:00
xrain c591387288 feat: SimApiControllerScanner 增加 ISimApiAuthChecker 自动扫描
scanAuthCheckers():扫描调用者包及其子包中 ISimApiAuthChecker 的非抽象实现类
(复用控制器扫描的 PackageInfo 枚举机制,对齐 C# Assembly.GetTypes 扫描)
2026-08-16 22:47:04 +08:00
xrain 6cb79cf970 feat: SimApiHttpClient 增强
- aesEncrypt 接入纯仓颉 AES(替换 Base64 占位)
- TLS 支持:https 自动配置 TrustAll + SNI(stdx TLS 动态加载 openssl)
- generateNonce 用 UUID v4(对齐 C# Guid.NewGuid)
- open class 供 SimApiAuthClient 继承
2026-08-16 22:47:04 +08:00
xrain 8a6c783459 feat: Auth/Cache/Util 增强
- SimApiAuth/Cache:Redis 连接串支持密码/DB 索引(host:port,password=xxx,db=2)
- InMemory 模式加过期机制(TokenEntry/CacheEntry 带 expireAt,过期自动移除)
- Redis 客户端 autoHello=false 兼容 Redis 8.x(HELLO 3 RESP3 响应解析失败)
- SimApiAuth.generateToken 用 UUID v4
- SimApiUtil 补齐:newGuid / fromJson<T> / base64Encode(Any) / base64DecodeTo<T>
2026-08-16 22:46:57 +08:00
xrain a73a986f8b feat: RequestLog 中间件补齐(对齐 C#)
- 请求体 JSON 字段级截断(仅超长字符串字段,保留结构)
- 下游异常捕获记录后重抛(对齐 C# ExceptionDispatchInfo)
- 响应体受 soulsoft body 只读限制,记录 Content-Length 替代
2026-08-16 22:46:57 +08:00
xrain a04ab2f400 feat: addSimApi/useSimApi 接入内置路由/AuthSDK/checker 扫描
- 内置路由:RouteOptions 自定义路径用 mapGet/mapPost 真实注册(默认值由特性路由覆盖)
- AuthGate:enableSimApiAuthGate 时注册 AuthClient/Center/Iam 单例并挂载网关中间件
- ISimApiAuthChecker 自动扫描:enableSimApiAuth 时扫描调用者包实现类填充 authCheckers + AddScoped
2026-08-16 22:46:57 +08:00
xrain c7b8148ff5 refactor: 提取统一响应封装 SimApiResultWriter
- dispatchResult 复用 SimApiResultWriter(IActionResult/SimApiBaseResponse/String/Unit/其他 五分支)
- @SimApiAuth 鉴权执行(checkSimApiAuth:401/类型403/checker 遍历)
- @OriginResponse 跳过封装原样输出
2026-08-16 22:46:48 +08:00
xrain f056de582b feat: AuthSDK 认证中心完整实现(对齐 C# AuthSDK)
- SimApiAuthClient:SimApiHttpClient 子类,凭证取 AuthCenterOptions
- SimApiAuthCenter:群组/Profile/内部应用/系统登录/安全验证 12 接口 + VerifySign
- SimApiAuthIam:注册权限/获取权限/校验权限(无权限 403)
- SimApiAuthCenterMiddleware:网关透传(三头 MD5 校验 + Base64 解码 LoginInfo)
- SimApiAuthDto:7 个 DTO(data 字段用 JsonValue 规避宏约束)
2026-08-16 22:46:48 +08:00
xrain 9af7871114 feat: SimApiBaseModel 实体基类(对齐 C# Models/SimApiBaseModel)
- _id 默认 UUID v4、_createdAt/_updatedAt 自动当前时间
- mapData 反射式字段映射(忽略字段/白名单两种重载)
- updateTime 刷新更新时间
2026-08-16 22:46:48 +08:00
xrain d68a31077a feat: 声明式鉴权/原样响应注解 + 服务端验签 + AES body 解密
- @SimApiAuth:方法/类级鉴权注解(401/类型403/checker执行),对齐 C# [SimApiAuth]
- @OriginResponse:跳过统一响应封装原样输出,对齐 C# [OriginResponse]
- SimApiSignChecker + SimApiSignProviderBase:服务端验签(含 QueryExpires 过期与 nonce 去重)
- SimApiAesBodyChecker + AesBodyProviderBase:服务端解密加密 body
2026-08-16 22:46:40 +08:00
60 changed files with 3530 additions and 1207 deletions
+2 -1
View File
@@ -1 +1,2 @@
target/
target/
*.cj.macrocall
+284 -63
View File
@@ -2,7 +2,29 @@
> 仓颉版 SimApiASP.NET Core 风格 API 基础框架,移植自 C# 项目 [SimApi](https://github.com/SimcuTeam/simapi-net)`E:\simcu\simapi-net`)。
提供**统一响应格式、异常拦截、Token 认证、缓存、工具集、HTTP 客户端**等 API 基础能力。
提供**统一响应格式、异常拦截、Token 认证、缓存、工具集、HTTP 客户端、S3 存储、声明式注解**等 API 基础能力。
---
## 引入
两种方式任选其一:
**方式一:中央仓**(需先 `cjpm publish` 发布 `simcu::simapi`
```toml
[dependencies]
"simcu::simapi" = "1.0.3"
```
**方式二:Git 仓库**
```toml
[dependencies]
"simcu::simapi" = { git = "https://gitcode.com/simcu/simapi-cj.git", version = "1.0.3" }
```
> 本地开发也可用 path 依赖:`"simcu::simapi" = { path = "../simapi-cj" }`
---
@@ -16,8 +38,9 @@ import soulsoft_web_routing.*
import soulsoft_web_hosting.*
import soulsoft_extensions_logging.*
import soulsoft_extensions_injection.*
import simapi.extensions.*
import simapi.communications.*
import simcu::simapi.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
main(args: Array<String>) {
let builder = WebHost.createBuilder(args)
@@ -25,19 +48,19 @@ main(args: Array<String>) {
builder.services.addLogging()
// 注册 SimApi 服务(与 addLogging 同样式)
builder.addSimApi { options =>
SimApiExtensions.addSimApi(builder) { options =>
options.enableSimApiAuth = true // Token 认证(未配 Redis 自动用 InMemory
options.enableSimApiCache = true // 缓存
options.enableSimApiException = true // 全局异常拦截
}
let host = builder.build()
host.useSimApi()
SimApiExtensions.useSimApi(host)
// 业务接口:返回统一响应格式
// 业务接口:返回对象自动封装为统一响应格式({code, message, data}
host.mapGet("hello") {
context =>
context.response.write(SimApiBaseResponse().toJsonString(dataJson: "\"hello cangjie.\""))
context.response.write(SimApiUtil.json(Some(SimApiResponse<String>("hello cangjie."))))
}
host.run()
@@ -58,6 +81,12 @@ main(args: Array<String>) {
| 404 | 资源不存在 |
| 500 | 服务器错误 |
响应 JSON(经 simcu::serialization 反射序列化,字段**无下划线**):
```json
{ "code": 200, "message": "成功", "data": { ... } }
```
### 异常处理流程
```
@@ -74,15 +103,22 @@ main(args: Array<String>) {
simapi-cj/
├── cjpm.toml # 包配置
├── src/
│ ├── communications/ # SimApiBaseResponse, PageResponse, SimApiLoginItem, ApiResult, 请求 DTO
│ ├── SimApiExtensions.cj # 根包入口:SimApiExtensions 静态类(addSimApi / useSimApi + 内置路由 + 响应封装)
│ ├── annotations/ # 声明式注解:@SimApiAuth(鉴权)、@OriginResponse(原样响应)、
│ │ # @SimApiSign(验签)、@AesBodyAES body 解密)
│ ├── authsdk/ # 认证中心 SDKSimApiAuthClient/Center/Iam + 网关中间件 + DTO
│ ├── communications/ # SimApiBaseResponse, PageResponse, SimApiLoginItem, 请求 DTO
│ ├── configurations/ # SimApiOptions + 各模块 Option(含 ConfigureSimApiXxx 回调)
│ ├── controllers/ # SimApiBaseController, SimApiCommonController, SimApiAuthControllerMVC 写法)
│ ├── exceptions/ # SimApiException
│ ├── extensions/ # SimApiExtensionsaddSimApi / useSimApi + 内置路由)
├── helpers/ # SimApiError, SimApiUtil, SimApiAuth, SimApiCache, SimApiHttpClient
├── interfaces/ # ISimApiAuthChecker
│ ├── helpers/ # SimApiError, SimApiUtil, SimApiAuth, SimApiCache, SimApiHttpClient,
# SimApiAesUtil(AES-256), SimApiSignChecker(验签), SimApiAesBodyChecker(AES body),
# SimApiStorage(S3/MinIO, 自实现 SigV4), SimApiRequestDelegateFactory, SimApiResultWriter
│ ├── interfaces/ # ISimApiAuthChecker, IBindRequestContext, SimApiSignProviderBase, AesBodyProviderBase
│ ├── logger/ # SimApiLogger, SimApiLoggerProvider(彩色日志)
── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware
── macros/ # ReadTomlVersion(编译期读版本号)
│ ├── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware
│ └── models/ # SimApiBaseModel(实体基类)
```
---
@@ -92,21 +128,22 @@ simapi-cj/
### 1. 错误处理 — SimApiError
```cangjie
import simapi.helpers.*
import simcu::simapi.helpers.*
SimApiError.error(500, "服务器内部错误") // 直接抛错
SimApiError.errorWhen(amount <= 0, 400, "金额无效") // 条件为 true 时抛错
SimApiError.errorWhenFalse(hasPermission, 403, "无权操作")
SimApiError.errorWhenNone(someOptional, 404, "用户不存在")
SimApiError.errorWhenNull(someOptional, 404, "用户不存在")
```
### 2. 认证 — SimApiAuth
```cangjie
import simapi.helpers.*
import simapi.communications.*
import simcu::simapi.helpers.*
import simcu::simapi.communications.*
let auth = SimApiAuth(redisConfiguration: "") // 配 Redis 用 Redis,否则 InMemory
// 由 DI 注入(构造参数 options: SimApiOptions,从配置读 RedisConfiguration;未配则 InMemory
let auth: SimApiAuth = ... // 例:控制器构造注入
let token = auth.login(SimApiLoginItem(id: "user-001")) // 默认 7 天
let login = auth.getLogin(token) // 获取登录信息
@@ -114,14 +151,94 @@ auth.logout(token) // 退出登录
auth.logoutAll("user-001") // 退出全部
```
- **Redis 模式**:配置 `RedisConfiguration`(如 `"localhost:6379"`时使用,支持多实例共享
- **InMemory 模式**:零配置,适合开发/测试;重启后登录态丢失
- **Redis 模式**:配置 `RedisConfiguration` 时使用,支持多实例共享。连接串格式:
- `"localhost:6379"`(基础)
- `"localhost:6379,password=xxx"`(带密码)
- `"localhost:6379,password=xxx,db=2"`(带密码 + DB 索引)
- **InMemory 模式**:零配置,适合开发/测试;登录态带过期时间(对齐 C# 过期语义),重启后丢失
- **Token 传参**Header `Token: <value>` 或 Query `token=<value>`
### 2.1 声明式鉴权 — @SimApiAuth(对齐 C# [SimApiAuth]
标注在控制器**方法或类**上,请求派发时自动执行鉴权(未登录 401 → 类型不匹配 403 → 遍历执行 `ISimApiAuthChecker`):
```cangjie
import simcu::simapi.annotations.{SimApiAuth}
@SimApiAuth // 类级:整个控制器需登录
public class MyController <: SimApiBaseController {
@SimApiAuth["admin"] // 方法级:仅 admin 类型可访问
@HttpPost["my/admin-only"]
public func adminOnly(): String { "ok" }
}
```
> 说明:仓颉注解参数须为编译期常量,`@SimApiAuth` 支持单个类型参数(`@SimApiAuth["admin"]`)或逗号分隔多类型(`@SimApiAuth["admin,user"]`,对齐 C# `type.Split(",")`);空参数表示任意已登录用户。
### 2.2 原样响应 — @OriginResponse
标注后跳过统一响应封装,接口返回什么就输出什么(对齐 C# `[OriginResponse]`):
```cangjie
import simcu::simapi.annotations.{OriginResponse}
@OriginResponse
@HttpGet["raw"]
public func raw(): String {
"{\"raw\":true}" // 直接输出,不包 {code,message,data}
}
```
### 2.3 声明式验签 — @SimApiSign(对齐 C# [SimApiSign]
标注在控制器**方法或类**上,请求派发时自动验签(appId 提取 → 密钥获取 → timestamp 过期校验 → nonce 去重 → MD5 比对):
```cangjie
import simcu::simapi.annotations.{SimApiSign}
import simcu::simapi.interfaces.{SimApiSignProviderBase}
// 1. 继承 Provider 实现密钥获取(并注册到 DI)
public class MySignProvider <: SimApiSignProviderBase {
public override func getKey(appId: ?String): ?String {
Some("my-secret-key")
}
}
// 2. 方法标注 @SimApiSign,自动验签(provider 类型名从 DI 解析)
@SimApiSign["MySignProvider"]
public func signedAction(): String { "ok" }
```
`SimApiSignProviderBase` 可配置:`appIdName` / `timestampName` / `nonceName` / `signName` / `queryExpires` / `duplicateRequestProtection` / `signFields`(与 C# 一致)。也可手动调用 `SimApiSignChecker.verify(context, provider, cache)`
### 2.4 声明式 AES body — @AesBody(对齐 C# [AesBody]
标注在**参数**上,请求派发时自动解密 `{"data":"密文"}` body 并反序列化为参数类型:
```cangjie
import simcu::simapi.annotations.{AesBody}
import simcu::simapi.interfaces.{AesBodyProviderBase}
public class MyAesProvider <: AesBodyProviderBase {
public override func getKey(appId: ?String): ?String {
Some("aes-secret-key")
}
}
public func create(@AesBody["MyAesProvider"] request: CreateRequest): String {
// request 已自动解密并反序列化
"ok"
}
```
也可手动调用 `SimApiAesBodyChecker.decryptBody(context, provider)` 获取明文 JSON 字符串。
### 3. 缓存 — SimApiCache
```cangjie
let cache = SimApiCache(redisConfiguration: "")
// 由 DI 注入(构造参数 options: SimApiOptions
let cache: SimApiCache = ...
cache.set("key", "value")
let v = cache.get("key") // ?String
cache.hasKey("key") // Bool
@@ -135,35 +252,74 @@ Key 自动加前缀 `SimApi:Cache:`。
```cangjie
SimApiUtil.cstNow // UTC+8 时间
SimApiUtil.timestampNow // 秒级时间戳
SimApiUtil.newGuid() // UUID v4(对齐 C# Guid.NewGuid()
SimApiUtil.md5("text") // 32 位十六进制
SimApiUtil.sha1("text") // 40 位
SimApiUtil.base64Encode("text") / base64Decode("...")
SimApiUtil.base64Encode(obj) // 对象 → JSON → Base64(对齐 C# Base64Encode(object)
SimApiUtil.json(obj) // 对象 → JSON 字符串(simcu::serialization 反射)
SimApiUtil.escapeJson(s) // JSON 字符串转义
SimApiUtil.fromJson<T>(json) // JSON → T(对齐 C# FromJson<T>,任意类免约束)
SimApiUtil.base64DecodeTo<T>(str) // Base64 → JSON → T
SimApiUtil.checkCell("13800138000") // 手机号
SimApiUtil.checkEmail("a@b.com") // 邮箱
```
> JSON 序列化/反序列化统一走 **simcu::serialization**`JsonSerializer.Serialize` / `Deserialize<T>`),任意类免标注、免接口约束。
### 4.1 AES 加解密 — SimApiAesUtil(对齐 C# SimApiAesUtil
纯仓颉实现 AES-256-CBC + PKCS7S-box/密钥扩展/轮函数),与 .NET 双向互操作已验证:
```cangjie
let encrypted = SimApiAesUtil.encrypt("明文", "key字符串") // Base64(随机IV + 密文)
let plain = SimApiAesUtil.decrypt(encrypted, "key字符串")
```
- 密钥:`SHA256(key 字符串)` → 32 字节;IV 每次随机 16 字节前置;输出 `Base64(IV + 密文)`
-`SimApiHttpClient.aesQuery<T>` / `aesSignQuery<T>` 使用
### 4.2 实体基类 — SimApiBaseModel(对齐 C# SimApiBaseModel
```cangjie
import simcu::simapi.models.*
public class User <: SimApiBaseModel {
public var _name: String = ""
}
let user = User() // _id 自动 GUID、_createdAt/_updatedAt 自动当前时间
user.mapData(source) // 反射映射:源对象同名同类型字段 → this(忽略 Id/CreatedAt/UpdatedAt
user.mapData(source, ["_name"]) // 白名单映射
user.updateTime() // 刷新 _updatedAt
```
### 5. HTTP 客户端 — SimApiHttpClient
用于调用其他带签名/AES 的 SimApi 服务:
用于调用其他带签名/AES 的 SimApi 服务**基于 stdx.net.http,不依赖 soulsoft_net_http**;内置 TLS`https` 自动配置信任所有证书 + SNI
```cangjie
let client = SimApiHttpClient(options: SimApiHttpClientOptions()) // 配置 server/appId/appKey
// 返回泛型 T(对齐 .NET SignQuery<T>/AesQuery<T>/AesSignQuery<T>),T 需实现 ISerialization<T>
// 返回泛型 T(对齐 .NET SignQuery<T>/AesQuery<T>/AesSignQuery<T>),T 任意类免约束
let resp1 = client.signQuery<SimApiLoginItem>("/api/hello", body: "{\"a\":1}")
let resp2 = client.aesQuery<SimApiLoginItem>("/api/data", body: "{\"a\":1}")
let resp3 = client.aesSignQuery<SimApiLoginItem>("/api/data", body: "{\"a\":1}")
```
签名参数名可配置(`signName / timestampName / nonceName / appIdName / signFields`,对齐 C# 的 virtual 属性)。AES 请求体用 `SimApiOneFieldRequest<String>` 序列化为 `{"data":"密文"}`(对齐 C#)。
### 5.1 请求日志 — enableRequestLog
记录每次请求的方法、URL、请求头、请求体、响应状态码耗时:
记录每次请求的方法、URL、请求头、请求体、响应状态码耗时与异常(对齐 C#
- 请求体按 **JSON 字段级截断**(仅对超长字符串字段截断,保留结构;非 JSON 整串截断)
- 下游异常**捕获记录后重抛**(对齐 C# ExceptionDispatchInfo
```cangjie
builder.addSimApi { options =>
SimApiExtensions.addSimApi(builder) { options =>
options.enableRequestLog = true
options.simApiRequestLogOptions.showFullHeader = true // 打印完整 Header(默认只打 Token/Query-Id
options.simApiRequestLogOptions.requestStringLogLength = 200 // 请求体截断长度(0 不截断)
options.simApiRequestLogOptions.requestStringLogLength = 200 // 请求体字段截断长度(0 不截断)
}
```
@@ -174,43 +330,74 @@ builder.addSimApi { options =>
*( RequestHeaders [Full] ) =>
{"host":"127.0.0.1:5000",...}
*( RequestBody ) =>
{"name":"AAAA...(200)","image":"x"}
*( Response [200] ) => 1.756400ms
```
### 5.2 日志格式 — SimApiLogger
`enableLogger`(默认 `true`)时自动使用 `SimApiLoggerProvider`(替换 soulsoft 默认控制台格式),输出格式对齐 C# 原版:
`enableLogger`(默认 `true`)时自动使用 `SimApiLoggerProvider`,输出格式对齐 C# 原版:
```
[ 分类 ][ 时间:毫秒 ][ 级别 ]
消息内容
```
按级别着色:
按级别着色:Debug 深紫 / Info 深青 / Warn 黄 / Error 红 / Fatal 深红。
| 级别 | 颜色 |
### 5.3 存储 — SimApiStorageS3/MinIO,对齐 C# SimApiStorage
`enableSimApiStorage = true` 时注册 `SimApiStorage`Scoped,内部自实现 AWS Signature V4,基于 stdx.net.http,无需 Minio SDK):
```cangjie
SimApiExtensions.addSimApi(builder) { options =>
options.enableSimApiStorage = true
options.configureSimApiStorage { storage =>
storage.endpoint = "http://192.168.0.2:9000" // 必须 http:// 或 https:// 开头
storage.serveUrl = "https://files.example.com" // 文件访问地址,不能以 / 结尾
storage.bucket = "app-files"
storage.accessKey = "minioadmin"
storage.secretKey = "minioadmin"
}
}
```
| 方法 | 说明 |
|------|------|
| Debug | 深紫(DarkMagenta |
| Info | 深青(DarkCyan |
| Warn | 黄(Yellow |
| Error | 红(Red |
| Fatal | 深红(DarkRed 粗体近似 |
| 其他 | 白(White |
| `getUploadUrl(path, expire=7200)` | 上传预签名 URL,返回 `GetUploadUrlResponse(UploadUrl, DownloadUrl, Path)` |
| `getDownloadUrl(path, expire=600)` | 下载预签名 URL |
| `uploadFile(path, data, contentType="image/png")` | 直接 PUT 上传(字节数组 |
| `deleteFiles(paths)` | 批量删除对象(S3 原生 DeleteObjects,一次请求删多个 |
| `fullUrl(path)` / `getUrl(path)` | 补全访问 URL`~/` 前缀依赖请求上下文 |
| `getPath(url)` | 从 URL 还原相对路径(去掉 Endpoint/Bucket 或 ServeUrl 前缀 |
> 说明:桶不存在时自动创建(对齐 C# BucketExists + MakeBucket,静态守卫只执行一次);
> 预签名与上传使用 AWS SigV4HMAC-SHA256 基于 stdx SHA256 自实现),已用 AWS 官方测试向量验证签名正确。
### 6. 内置路由(UseSimApi 自动注册)
| 路由 | 方法 | 条件 | 说明 |
| ----------------- | -------- | ---------------------------- | -------------------------- |
| `/versions` | GET/POST | 始终 | 返回 SimApi/App 版本 |
| `/user/info` | POST | `enableSimApiAuth` | 需登录,返回 LoginInfo |
| `/auth/logout` | POST | `enableSimApiAuth` | 退出登录 |
| `/exception/{code}` | GET | 始终 | 错误反馈 |
| `/exception/{code}` | GET | 始终 | 错误反馈(抛 SimApiException |
路由路径可自定义(`configureSimApiRoute`):
```cangjie
options.configureSimApiRoute { route =>
route.userInfoRoute = Some("/my/user/info") // 自定义路径生效
route.logoutRoute = Some("/my/auth/logout")
route.webConfigRoute = Some("/my/config")
}
```
### 7. 认证后处理 Hook — ISimApiAuthChecker
实现后每次认证成功都会调用(配合 `@SimApiAuth` 注解或手动 `requireLogin`):
```cangjie
import simapi.interfaces.*
import simcu::simapi.interfaces.*
class MyAuthChecker <: ISimApiAuthChecker {
public func run(loginItem: SimApiLoginItem, token: String): Unit {
@@ -219,13 +406,45 @@ class MyAuthChecker <: ISimApiAuthChecker {
}
```
### 8. 认证中心 SDK — AuthSDK(对齐 C# AuthSDK
`enableSimApiAuthGate = true` 时注册 `SimApiAuthClient` / `SimApiAuthCenter` / `SimApiAuthIam` 单例并挂载网关透传中间件:
```cangjie
SimApiExtensions.addSimApi(builder) { options =>
options.enableSimApiAuthGate = true
options.configureSimApiAuthCenter { auth =>
auth.server = "https://auth.example.com"
auth.appId = "app-id"
auth.appKey = "app-key"
}
}
```
| 类 | 说明 |
|----|------|
| `SimApiAuthClient` | `SimApiHttpClient` 子类,凭证取 AuthCenterOptions |
| `SimApiAuthCenter` | 群组/Profile/内部应用/系统登录/安全验证等 12 个接口 + `VerifySign` |
| `SimApiAuthIam` | 注册权限 / 获取权限标识 / 校验权限(无权限抛 403) |
| `SimApiAuthCenterMiddleware` | 网关透传:`X-SimApi-Gate-Auth/Time/Sign` 三头 MD5 校验 → Base64 解码 LoginInfo |
```cangjie
import simcu::simapi.authsdk.*
let center = SimApiAuthCenter(client) // client 从 DI 注入
let groups = center.groupRelated(profileId) // 群组列表
let loginInfo = center.getLoginInfo(code) // 登录信息(场景校验)
let iam = SimApiAuthIam(client)
iam.checkPermission(profileId, "app:create") // 无权限抛 403
```
---
## SimApiOptions 完整配置
```cangjie
builder.addSimApi { options =>
options.redisConfiguration = "localhost:6379" // Redis(可选)
SimApiExtensions.addSimApi(builder) { options =>
options.redisConfiguration = "localhost:6379" // Redis(可选,支持 ,password=xxx,db=2
// 功能开关
options.enableSimApiAuth = false // Token 认证
@@ -233,14 +452,16 @@ builder.addSimApi { options =>
options.enableSimApiException = true // 全局异常拦截
options.enableSimApiResponseFilter = true // 响应统一封装
options.enableSimApiHttpClient = false // HTTP 客户端
options.enableSimApiAuthGate = false // 认证中心 SDK + 网关中间件
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")
route.userInfoRoute = Some("/user/info") // 内置路由自定义路径
route.logoutRoute = Some("/auth/logout")
route.webConfigRoute = Some("/config")
}
options.configureSimApiRequestLog { opt =>
opt.showFullResponse = true
@@ -252,56 +473,56 @@ builder.addSimApi { options =>
http.appKey = "your-app-key"
http.server = "https://api.example.com"
}
options.configureSimApiAuthCenter { auth =>
auth.server = "https://auth.example.com"
auth.appId = "auth-app-id"
auth.appKey = "auth-app-key"
}
}
```
## 内置控制器(MVC 写法)
simapi 提供 Spire MVC 控制器(继承 `SimApiBaseController`),宿主通过 `addControllers` + 手动 `AssemblyPart` 注册(当前 cjc 无法自动扫描包子包):
simapi 提供 Spire MVC 控制器(继承 `SimApiBaseController`),`addSimApi` 自动注册内置控制器 + 自动扫描调用者包中的控制器(对齐 C# `Assembly.GetTypes()` 扫描):
| 控制器 | 路由 | 说明 |
|--------|------|------|
| `SimApiCommonController` | `/exception/{code}``/webconfig``/user/info` | 通用内置路由 |
| `SimApiCommonController` | `/exception/{code}``/config``/user/info` | 通用内置路由 |
| `SimApiAuthController` | `/auth/logout` | 退出登录 |
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` |
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` / `getLogin()` |
```cangjie
import simapi.controllers.*
import simcu::simapi.controllers.*
import simcu::simapi.annotations.{SimApiAuth}
// 控制器写法:继承 SimApiBaseController,注解路由 + DI 注入
@SimApiAuth // 类级鉴权(可选,替代 requireLogin
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()
public func myAction(@FromBody request: MyRequest): String {
"ok"
}
}
// 宿主注册
let mvc = builder.services.addControllers()
mvc.addApplicationPart(AssemblyPart("simapi.controllers", [
TypeInfo.of<SimApiCommonController>(),
TypeInfo.of<SimApiAuthController>(),
]))
```
宿主无需手动注册控制器:`builder.addSimApi {}` 内部自动扫描并注册。
---
## 未实现模块(选项占位)
以下 C# 原包功能因仓颉生态暂无对应库,**选项保留但未实现**:
以下 C# 原包功能因仓颉生态暂无对应库Hangfire/MQTT/Swashbuckle**选项保留但未实现**
| 选项 | 原功能 | 状态 |
|------|--------|------|
| `enableSimApiDoc` | Swagger 文档 | ❌ 未实现 |
| `enableSimApiStorage` | S3/MinIO 存储 | ❌ 未实现 |
| `enableSimApiDoc` | Swagger 文档(可换 soulsoft_web_openapi | ❌ 未实现 |
| `enableSynapse` | MQTT 通信 | ❌ 未实现 |
| `enableJob` | Hangfire 任务调度 | ❌ 未实现 |
| `enableSimApiAuthGate` | Auth Center 网关鉴权 | ❌ 未实现 |
| `SimApiAesUtil` | AES-256-CBC | ⚠️ 仓颉 std 无 AES,暂用 Base64 占位 |
> ✅ 已实现(曾为占位):`enableSimApiStorage`S3/MinIO,自实现 AWS SigV4)、`enableSimApiAuthGate`AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC,与 .NET 双向互操作)、`ISimApiAuthChecker`、`@SimApiSign` / `@AesBody` 声明式注解、内置路由自定义路径。
---
@@ -313,9 +534,9 @@ mvc.addApplicationPart(AssemblyPart("simapi.controllers", [
| `soulsoft_extensions_logging` 系列 | 日志 |
| `soulsoft_extensions_injection` | 依赖注入 |
| `soulsoft_extensions_configuration` | 配置 |
| `soulsoft_serialization` | JSON 序列化 |
| `simcu::serialization`path 依赖) | JSON 序列化simapi 自研,反射免标注) |
| `redis`pkg.cangjie-lang.cn | Redis 客户端(认证/缓存 Redis 模式) |
| `stdx`CANGJIE_STDX_PATH | 标准扩展库(md5/sha1/base64/http |
| `stdx`CANGJIE_STDX_PATH | 标准扩展库(md5/sha1/base64/http/tls |
> 构建前需设置 `CANGJIE_STDX_PATH` 指向本地 stdx 的 `static/stdx` 目录。
+14 -15
View File
@@ -1,20 +1,19 @@
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_web_http = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
soulsoft_extensions_options = {version = "1.0.20260528"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_identity_claims = {version = "1.0.20260528"}
soulsoft_web_hosting = {version = "1.0.20260528"}
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
soulsoft_extensions_hosting = {version = "1.0.20260528"}
soulsoft_web_mvc = {version = "1.0.20260528"}
soulsoft_extensions_configuration = {version = "1.0.20260528"}
redis = {version = "1.0.20260627"}
soulsoft_web_cors = {version = "1.0.20260528"}
soulsoft_serialization = {version = "1.0.20260528"}
soulsoft_extensions_injection = {version = "1.0.20260528"}
+3 -3
View File
@@ -1,8 +1,9 @@
[package]
cjc-version = "1.1.3"
name = "simapi"
organization = "simcu"
description = "SimApi 仓颉版:ASP.NET Core 风格 API 基础框架(统一响应/异常拦截/Token认证/缓存/工具集/HTTP客户端)"
version = "5.2.12"
version = "1.0.3"
target-dir = ""
output-type = "static"
override-compile-option = ""
@@ -21,9 +22,8 @@
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"
"simcu::serialization" = { path = "../simapi-serialization" }
[target]
[target.x86_64-w64-mingw32]
+354
View File
@@ -0,0 +1,354 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*
* 对齐 .NET:根命名空间 SimApi 下的静态类 SimApiExtensionsAddSimApi + UseSimApi)。
* 本文件同时是 simapi 根包的入口锚点:cjpm 要求 src 根目录至少有一个 .cj 文件,
* 否则不会扫描 src 子目录(helpers/controllers/...),整个包将编译为空。
*
* 使用方式:
* ```
* let builder = WebHost.createBuilder(args)
* builder.services.addLogging()
* SimApiExtensions.addSimApi(builder) { options =>
* options.enableSimApiAuth = true
* }
* let host = builder.build()
* SimApiExtensions.useSimApi(host)
* host.run()
* ```
*/
package simcu::simapi
import std.collection.*
import std.convert.*
import std.reflect.*
import std.time.*
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 simcu::simapi.authsdk.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.controllers.*
import simcu::simapi.helpers.*
import simcu::simapi.interfaces.*
import simcu::simapi.logger.*
import simcu::simapi.middlewares.*
/**
* SimApi 扩展入口(对齐 C# 根命名空间 SimApi 的静态类 SimApiExtensions)。
*/
public class SimApiExtensions {
private init() {}
/**
* 注册 SimApi 服务到 WebHostBuilder(自动 addRouting + addControllers + 扫描控制器)。
* @param builder 主机构建器。
* @param configure 配置回调。
* @return 当前构建器。
*/
public static func addSimApi(builder: WebHostBuilder, configure: (SimApiOptions) -> Unit): WebHostBuilder {
// 自动注册路由(对齐 builder.Services.AddRouting()
builder.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) {
builder.services.addSingleton<IRequestDelegateFactory, SimApiRequestDelegateFactory>()
}
// 自动注册 MVC + 控制器(对齐 builder.Services.AddControllers()
// 自动扫描调用者包中的 Controller 子类(对齐 C# 的 Assembly.GetTypes() 扫描)
let controllers = SimApiControllerScanner.scan()
addControllers(builder.services, controllers)
// 注册 SimApi 服务
addSimApiCore(builder, options)
return builder
}
/**
* 注册 SimApi 服务(默认配置,自动扫描控制器)。
* @param builder 主机构建器。
* @return 当前构建器。
*/
public static func addSimApi(builder: WebHostBuilder): WebHostBuilder {
addSimApi(builder, {_ =>})
}
/**
* 应用 SimApi 中间件与内置路由(日志输出对齐 C# UseSimApi)。
* @param host 构建完成的主机。
*/
public static func useSimApi(host: WebHost): Unit {
let options = host.services.getOrThrow<SimApiOptions>()
let loggerFactory = host.services.getOrThrow<ILoggerFactory>()
// 对齐 C# ILogger<SimApiOptions>:分类名为 SimApiOptions 的全限定名
let logger = loggerFactory.createLogger<SimApiOptions>()
// ===== 基础信息(对齐 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(已实现:addSimApi 中注册 Scoped,桶初始化惰性执行;
// 对齐 C# 的 GetService 预热,但 scoped 服务不能从根解析,故仅输出配置日志)
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# 挂载顺序(先挂载 = 外层):ForwardedHeaders(L419) → CORS(L425) → AuthGate(L454) → Auth(L462)
// → 内置路由(L465-496) → Swagger(L502) → RequestLog(L519) → Exception(L525) → LowerUrl → Job
// ForwardedHeaders(占位:soulsoft 暂无内置,对齐 C# 最先挂载)
if (options.enableForwardHeaders) {
logger.info("开始配置ForwardedHeaders...")
}
// CORS(对齐 C# builder.UseCors("any")
if (options.enableCors) {
logger.info("开始配置 Cors全部允许...")
host.useCors()
}
// AuthGate(对齐 C# UseMiddleware<SimApiAuthCenterMiddleware>
if (options.enableSimApiAuthGate) {
logger.info("开始配置 SimApiAuthGate...")
host.use<SimApiAuthCenterMiddleware>()
}
// 认证中间件(对齐 C# builder.UseMiddleware<SimApiAuthMiddleware>()
if (options.enableSimApiAuth) {
logger.info("开始配置 SimApiAuth...")
host.use<SimApiAuthMiddleware>()
}
// 内置路由:RouteOptions 自定义路径时真实注册(默认路径已由内置控制器特性路由覆盖,
// 对齐 C# MapControllerRoute 语义;soulsoft 无约定路由 defaults,用 mapGet/mapPost 委托实现)
let routeOptions = options.simApiRouteOptions
if (let Some(route) <- routeOptions.userInfoRoute) {
if (route != "/user/info") {
host.mapPost(route, { context =>
let controller = ActivatorUtilities.createInstance(context.services,
TypeInfo.of<SimApiCommonController>())
if (let c: SimApiBaseController <- controller) {
c.bindRequestContext(context)
}
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.userInfo())
}
})
}
logger.info("注册内置Route: UserInfo => ${route}")
}
if (let Some(route) <- routeOptions.logoutRoute) {
if (route != "/auth/logout") {
host.mapPost(route, { context =>
let controller = ActivatorUtilities.createInstance(context.services,
TypeInfo.of<SimApiAuthController>())
if (let c: SimApiBaseController <- controller) {
c.bindRequestContext(context)
}
if (let c: SimApiAuthController <- controller) {
SimApiResultWriter.write(context, c.logout())
}
})
}
logger.info("注册内置Route: Logout => ${route}")
}
if (let Some(route) <- routeOptions.webConfigRoute) {
if (route != "/config") {
host.mapGet(route, { context =>
let controller = ActivatorUtilities.createInstance(context.services,
TypeInfo.of<SimApiCommonController>())
if (let c: SimApiBaseController <- controller) {
c.bindRequestContext(context)
}
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.webConfig())
}
})
host.mapPost(route, { context =>
let controller = ActivatorUtilities.createInstance(context.services,
TypeInfo.of<SimApiCommonController>())
if (let c: SimApiBaseController <- controller) {
c.bindRequestContext(context)
}
if (let c: SimApiCommonController <- controller) {
SimApiResultWriter.write(context, c.webConfigPost())
}
})
}
logger.info("注册内置Route: WebConfig => ${route}")
}
// SimApiDoc(占位,对齐 C# UseSwagger/UseSwaggerUI
if (options.enableSimApiDoc) {
logger.info("开始配置 SimApiDoc...")
}
// 请求日志中间件(对齐 C# builder.UseMiddleware<SimApiRequestLogMiddleware>()
if (options.enableRequestLog) {
logger.info("开始配置 SimApiRequestLog...")
host.use<SimApiRequestLogMiddleware>()
}
// 异常中间件最后挂载(最内层,对齐 C# builder.UseMiddleware<SimApiExceptionMiddleware>()
if (options.enableSimApiException) {
logger.info("开始配置 SimApiException...")
host.use<SimApiExceptionMiddleware>()
}
// 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...")
}
// 映射控制器端点(对齐 C# UseSimApi 中的 MapControllers
let callSiteFactory = host.services.getOrThrow<IServiceProviderIsService>()
if (callSiteFactory.isService<ApplicationPartManager>()) {
host.mapControllers()
}
}
// ===== 私有辅助 =====
private static func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): WebHostBuilder {
// 注册单例配置(对齐 C# builder.AddSingleton(simApiOptions)
builder.services.addSingleton<SimApiOptions>(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<T>(),其中间件由 UseMiddleware 创建)
// 认证(DI 自动注入 SimApiOptions
if (options.enableSimApiAuth) {
builder.services.addSingleton<SimApiAuth, SimApiAuth>()
}
// ISimApiAuthChecker 自动扫描注册(对齐 C# AddSimApi 中遍历调用者程序集 AddScoped(ISimApiAuthChecker, type)
// 扫描调用者包中的实现类,按【接口】注册;执行时用 getAll<ISimApiAuthChecker>() 一次解析全部实现,
// 无需在 SimApiOptions 里维护类型列表。
if (options.enableSimApiAuth) {
let checkers = SimApiControllerScanner.scanAuthCheckers()
for (checkerType in checkers) {
builder.services.addScoped(TypeInfo.of<ISimApiAuthChecker>(), checkerType)
}
}
// 缓存(DI 自动注入 SimApiOptions
if (options.enableSimApiCache) {
builder.services.addSingleton<SimApiCache, SimApiCache>()
}
// HTTP 客户端(DI 自动注入 SimApiOptions
if (options.enableSimApiHttpClient) {
builder.services.addSingleton<SimApiHttpClient, SimApiHttpClient>()
}
// 存储(S3/MinIO,对齐 C# AddHttpContextAccessor + AddSingleton<SimApiStorage>
// 仓颉版注册为 Scoped 以便注入 IHttpContextAccessorDI 禁止 singleton 消费 scoped),
// 桶初始化由静态守卫保证只执行一次)
if (options.enableSimApiStorage) {
builder.services.addHttpContextAccessor()
builder.services.addScoped<SimApiStorage, SimApiStorage>()
}
// AuthGate 认证中心 SDK(对齐 C# 注册 SimApiAuthClient/Center/Iam 单例)
if (options.enableSimApiAuthGate) {
builder.services.addSingleton<SimApiAuthClient, SimApiAuthClient>()
builder.services.addSingleton<SimApiAuthCenter, SimApiAuthCenter>()
builder.services.addSingleton<SimApiAuthIam, SimApiAuthIam>()
}
// 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
}
/// 注册 MVC 服务,并注册 SimApi 内置控制器 + 用户控制器到 ApplicationPartManager(对齐 .NET AddControllers())。
private static func addControllers(services: ServiceCollection, controllerTypes: Array<TypeInfo>): MvcBuilder {
// 调用 soulsoft_web_mvc 的无参 addControllers() 注册 MVC 核心服务
let mvc = services.addControllers()
let types = ArrayList<TypeInfo>()
// SimApi 内置控制器
types.add(TypeInfo.of<SimApiCommonController>())
types.add(TypeInfo.of<SimApiAuthController>())
// 用户控制器
for (t in controllerTypes) {
types.add(t)
}
let part = AssemblyPart("simcu::simapi.controllers", types.toArray())
mvc.addApplicationPart(part)
mvc
}
}
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 声明式 AES body 解密注解(对齐 C# SimApi.Attributes.AesBodyAttribute)。
*
* 标注在控制器方法参数上,请求派发时(SimApiRequestDelegateFactory)自动解密并反序列化:
* - 读取请求体 {"data": "密文"}
* - 通过 keyProviderDI 解析)获取密钥
* - SimApiAesUtil.decrypt 解密得到明文 JSON
* - simapi_serialization 按参数类型反序列化
*
* 用法:
* public func create(@AesBody request: CreateRequest): Unit {
*
* 说明:仓颉注解参数须为编译期常量,无法直接持有 Type;
* 故 keyProvider 用类型名 String,运行时经 TypeInfo.get 解析后从 DI 取实例。
*/
package simcu::simapi.annotations
@Annotation[target: [Parameter]]
public class AesBody {
/// AES 密钥提供器类型名(DI 注册的 AesBodyProviderBase 实现类名)
public let keyProvider: String
public const init() {
this.keyProvider = ""
}
public const init(keyProvider: String) {
this.keyProvider = keyProvider
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.annotations
/**
* 原样响应注解(对齐 C# SimApi.Attributes.OriginResponseAttribute)。
*
* 标注在控制器方法或类上,请求派发时 SimApiRequestDelegateFactory 跳过
* 统一响应封装(SimApiBaseResponse 包装),接口返回什么就输出什么。
*
* 用法:
* @OriginResponse
* public func raw(): String { "hello" } // 直接输出 "hello",不包 {code,message,data}
*/
@Annotation[target: [MemberFunction, Type]]
public class OriginResponse {
public const init() {}
}
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.annotations
/**
* 声明式鉴权注解(对齐 C# SimApi.Attributes.SimApiAuthAttribute)。
*
* 标注在控制器方法或类上,请求派发时(SimApiRequestDelegateFactory)自动执行鉴权:
* - 未登录(无 LoginInfo)→ 401
* - type 非空且登录用户类型不匹配 → 403
* - 遍历执行所有已注册的 ISimApiAuthChecker
*
* 用法:
* @SimApiAuth // 任意已登录用户
* @SimApiAuth["admin"] // 仅 admin 类型
* @SimApiAuth["admin,user"] // admin 或 user 类型(对齐 C# type.Split(",")
*
* 说明:仓颉注解参数须为编译期常量,String 无法作为 const 值数组元素,
* 故与 C# 的 string[] 不同,这里用逗号分隔字符串对齐 C# 多类型。
*/
@Annotation[target: [MemberFunction, Type]]
public class SimApiAuth {
/// 允许访问的用户类型(空 = 任意已登录用户)
public let `type`: String
public const init() {
this.`type` = ""
}
public const init(`type`: String) {
this.`type` = `type`
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 声明式签名校验注解(对齐 C# SimApi.Attributes.SimApiSignAttribute)。
*
* 标注在控制器方法或类上,请求派发时(SimApiRequestDelegateFactory)自动执行验签:
* - 提取 appId / timestamp / nonce / signQuery 优先,其次 Header
* - 通过 keyProviderDI 解析)获取密钥
* - 过期校验 + nonce 去重(需缓存)
* - 拼接 SignFields + appId + timestamp + nonce + keyMD5 比对
*
* 用法:
* @SimApiSign // 默认 SimApiSignProviderBase(应用需注册实现)
* @SimApiSign["MySignProvider"] // 指定 provider 类型名(DI 注册的实现类)
*
* 说明:仓颉注解参数须为编译期常量,无法直接持有 Type;
* 故 keyProvider 用类型名 String,运行时经 TypeInfo.get 解析后从 DI 取实例。
*/
package simcu::simapi.annotations
@Annotation[target: [MemberFunction, Type]]
public class SimApiSign {
/// 签名提供器类型名(DI 注册的 SimApiSignProviderBase 实现类名)
public let keyProvider: String
public const init() {
this.keyProvider = ""
}
public const init(keyProvider: String) {
this.keyProvider = keyProvider
}
}
+265
View File
@@ -0,0 +1,265 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK/SimApiAuthCenter:认证中心远程 SDK。
*/
package simcu::simapi.authsdk
import std.collection.*
import std.io.*
import stdx.net.http.*
import stdx.net.tls.*
import stdx.net.tls.common.*
import simcu::serialization.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
/**
* 认证中心远程 SDK(对齐 C# SimApiAuthCenter):
* 群组 / Profile / 内部应用 / 系统登录 / 安全验证 等接口,走签名请求。
*/
public class SimApiAuthCenter {
private let _client: SimApiAuthClient
public init(client: SimApiAuthClient) {
this._client = client
}
public prop client: SimApiAuthClient {
get() {
_client
}
}
// ===== 公共 =====
/**
* 委托 AuthCenter 进行应用签名验证(对齐 C# VerifySign)。
*/
public func verifySign(appId: String, timestamp: String, nonce: String, sign: String): Unit {
let url = "${_client.server}/api/auth/sign/verify?appId=${appId}&timestamp=${timestamp}&nonce=${nonce}&sign=${sign}"
let http = ClientBuilder().
noProxy().
tlsConfig(buildTlsConfig(_client.server)).
build()
try {
let request = HttpRequestBuilder().
post().
url(url).
header("Content-Type", "application/json").
body("{}").
build()
let response = http.send(request)
try {
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "HTTP ERROR: ${response.status}")
let json = readBodyText(response.body)
let resp = JsonSerializer.Deserialize<SimApiBaseResponse>(json)
SimApiError.errorWhen(resp.code != 200, code: 400, message: "签名验证失败")
} finally {
response.close()
}
} finally {
http.close()
}
}
/// 构建 TLS 配置:信任所有证书 + SNI 域名
private static func buildTlsConfig(server: String): TlsClientConfig {
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
match (server.indexOf("://")) {
case Some(i) =>
let rest = server[i + 3..]
let slash = rest.indexOf("/") ?? rest.size
let q = rest.indexOf("?") ?? rest.size
let end = if (slash < q) { slash } else { q }
let host = rest[0..end]
if (!host.isEmpty()) {
tls.serverName = Some(host)
}
case None => ()
}
tls
}
/// 2xx 视为成功
private static func isSuccess(status: UInt16): Bool {
status >= 200 && status < 300
}
/// 读取响应体 InputStream 为字符串
private static func readBodyText(body: InputStream): String {
var buffer = Array<Byte>(4096, repeat: 0)
var sb = StringBuilder()
var read = body.read(buffer)
while (read > 0) {
sb.appendFromUtf8(buffer.slice(0, read))
read = body.read(buffer)
}
sb.toString()
}
// ===== 群组相关 =====
/**
* 根据 profileId 获取群组列表(对齐 C# GroupRelated)。
*/
public func groupRelated(profileId: String): Array<GroupRelatedItem> {
_client.signQuery<Array<GroupRelatedItem>>("/api/auth/group/related",
body: simpleBody("profileId", profileId))
}
/**
* 按关键字搜索群组,输入群组 ID 精准搜索(对齐 C# GroupSearch)。
*/
public func groupSearch(keyword: String, skip!: Int64 = 0, take!: Int64 = 20): Array<AppAndProfileItem> {
var body = HashMap<String, Any>()
body["keyword"] = keyword
body["skip"] = skip
body["take"] = take
_client.signQuery<Array<AppAndProfileItem>>("/api/auth/group/search", body: SimApiUtil.json(Some(body)))
}
/**
* 使用组 ID 以及组内成员/管理员 profile 获取组的详细树结构(对齐 C# GroupDetail)。
*/
public func groupDetail(groupId: String, profileId: String): GroupDetailTreeNode {
var body = HashMap<String, Any>()
body["profileId"] = profileId
body["groupId"] = groupId
_client.signQuery<GroupDetailTreeNode>("/api/auth/group/detail", body: SimApiUtil.json(Some(body)))
}
/**
* 获取 profile 在本组的所有子组(对齐 C# GroupRelatedIndex)。
*/
public func groupRelatedIndex(groupId: String, profileId: String): Array<String> {
var body = HashMap<String, Any>()
body["groupId"] = groupId
body["profileId"] = profileId
_client.signQuery<Array<String>>("/api/auth/internal/group/related-group-ids",
body: SimApiUtil.json(Some(body)))
}
// ===== Profile 相关 =====
/**
* 按关键字搜索用户 Profile(对齐 C# ProfileSearch)。
*/
public func profileSearch(keyword: String, skip!: Int64 = 0, take!: Int64 = 20): Array<AppAndProfileItem> {
var body = HashMap<String, Any>()
body["keyword"] = keyword
body["skip"] = skip
body["take"] = take
_client.signQuery<Array<AppAndProfileItem>>("/api/auth/profile/search", body: SimApiUtil.json(Some(body)))
}
/**
* 通过 id 批量获取用户基本信息(对齐 C# ProfileList)。
*/
public func profileList(ids: Array<String>): Array<AppAndProfileItem> {
var body = HashMap<String, Any>()
var arr = ArrayList<Any>()
for (id in ids) {
arr.add(id)
}
body["ids"] = arr.toArray()
_client.signQuery<Array<AppAndProfileItem>>("/api/auth/profile/list", body: SimApiUtil.json(Some(body)))
}
// ===== AuthGate 内部应用专用 =====
/**
* 获取是否为 App 的拥有者(对齐 C# CheckIsAppOwner,字段为 PascalCase)。
*/
public func checkIsAppOwner(profileId: String, applicationId: String): Bool {
var body = HashMap<String, Any>()
body["ProfileId"] = profileId
body["AppId"] = applicationId
_client.signQuery<Bool>("/api/auth/internal/app/check-owner", body: SimApiUtil.json(Some(body)))
}
/**
* 根据用户 profileId 和提供的 appIds 获取应用列表(对齐 C# GetAppList,字段为 PascalCase)。
*/
public func getAppList(profileId: String, appIds: Array<String>): Array<AppAndProfileItem> {
var body = HashMap<String, Any>()
body["ProfileId"] = profileId
var arr = ArrayList<Any>()
for (id in appIds) {
arr.add(id)
}
body["AllowedAppIds"] = arr.toArray()
_client.signQuery<Array<AppAndProfileItem>>("/api/auth/internal/app/related",
body: SimApiUtil.json(Some(body)))
}
// ===== 系统登录 =====
/**
* 获取登录授权 CODE(对齐 C# GetLoginCode)。
* @param scene 场景标识。
* @param data 附加数据。
* @param backUrl 回调地址。
* @return GetCodeResponse(含 Code/Server/FullUrl)。
*/
public func getLoginCode(scene!: ?String = None, data!: ?HashMap<String, Any> = None,
backUrl!: ?String = None): GetCodeResponse {
var body = HashMap<String, Any>()
if (let Some(scene) <- scene) { body["scene"] = scene }
if (let Some(data) <- data) { body["data"] = data }
if (let Some(backUrl) <- backUrl) { body["backUrl"] = backUrl }
let code = _client.signQuery<String>("/api/auth/login/code", body: SimApiUtil.json(Some(body)))
let server = _client.server
GetCodeResponse(code, server, "${server}/auth?code=${code}")
}
/**
* 使用 code 获取登录信息(对齐 C# GetLoginInfo,场景不匹配抛 403003)。
*/
public func getLoginInfo(code: String, scene!: ?String = None): LoginInfoResponse {
var body = HashMap<String, Any>()
body["code"] = code
// 说明:C# 的 ErrorWhenNull(resp, 400232, "登录信息获取失败") 对应 signQuery 内部 data.getOrThrow() 的
// None 分支;仓颉版 signQuery 返回非空 T(data 缺失即抛异常),故此处无需重复判空。
let resp = _client.signQuery<LoginInfoResponse>("/api/auth/login/get", body: SimApiUtil.json(Some(body)))
SimApiError.errorWhen(resp.scene != scene, code: 403003, message: "登录场景不匹配")
resp
}
// ===== 安全验证 =====
/**
* 获取安全验证代码(对齐 C# GetConfirmCode)。
*/
public func getConfirmCode(scene: String, userId: String, data!: ?HashMap<String, Any> = None,
backUrl!: ?String = None): GetCodeResponse {
var body = HashMap<String, Any>()
body["scene"] = scene
if (let Some(data) <- data) { body["data"] = data }
if (let Some(backUrl) <- backUrl) { body["backUrl"] = backUrl }
body["profileId"] = userId
let code = _client.signQuery<String>("/api/auth/confirm/code", body: SimApiUtil.json(Some(body)))
let server = _client.server
GetCodeResponse(code, server, "${server}/confirm?code=${code}")
}
/**
* 使用安全验证 code 获取验证结果(对齐 C# Confirm,身份/场景不匹配分别抛 403002/403003)。
*/
public func confirm(code: String, scene: String, userId!: ?String = None): ConfirmResponse {
var body = HashMap<String, Any>()
body["code"] = code
let resp = _client.signQuery<ConfirmResponse>("/api/auth/confirm/get", body: SimApiUtil.json(Some(body)))
SimApiError.errorWhen(userId != Some(resp.profileId), code: 403002, message: "安全确认身份不匹配")
SimApiError.errorWhen(resp.scene != scene, code: 403003, message: "安全确认场景不匹配")
resp
}
/// 简单单字段请求体:{"field":"value"}
private static func simpleBody(field: String, value: String): String {
"{\"${field}\":\"${SimApiUtil.escapeJson(value)}\"}"
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK/SimApiAuthCenterMiddleware:网关透传认证中间件。
*/
package simcu::simapi.authsdk
import soulsoft_web_http.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
/**
* 网关透传认证中间件(对齐 C# SimApiAuthCenterMiddleware):
* 当请求带 X-SimApi-Gate-Auth / X-SimApi-Gate-Time / X-SimApi-Gate-Sign 三头时,
* 校验 MD5 签名(appId=..&auth=..&time=..&appKey=..),通过则 Base64 解码登录信息写入 LoginInfo。
*/
public class SimApiAuthCenterMiddleware <: IMiddleware {
private let _options: SimApiOptions
public init(options: SimApiOptions) {
this._options = options
}
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
let auth = context.request.headers.get("X-SimApi-Gate-Auth")
let time = context.request.headers.get("X-SimApi-Gate-Time")
let sign = context.request.headers.get("X-SimApi-Gate-Sign")
if (auth != None && time != None && sign != None) {
let authValue = auth.getOrThrow()
let timeValue = time.getOrThrow()
let signValue = sign.getOrThrow()
if (!authValue.isEmpty()) {
let authOptions = _options.simApiAuthCenterOptions
let signStr = "appId=${authOptions.appId}&auth=${authValue}&time=${timeValue}&appKey=${authOptions.appKey}"
if (SimApiUtil.md5(signStr) == signValue) {
let login = SimApiUtil.base64DecodeTo<SimApiLoginItem>(authValue)
context.items["LoginInfo"] = login
}
}
}
next(context)
}
}
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK/SimApiAuthClient:认证中心专用签名客户端。
*/
package simcu::simapi.authsdk
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
/**
* 认证中心签名客户端(对齐 C# SimApiAuthClient):
* SimApiHttpClient 子类,凭证(Server/AppId/AppKey)取自 SimApiAuthCenterOptions。
*/
public class SimApiAuthClient <: SimApiHttpClient {
/**
* @param options SimApi 配置(使用 simApiAuthCenterOptions 的 Server/AppId/AppKey)。
*/
public init(options: SimApiOptions) {
super(options: options)
let auth = options.simApiAuthCenterOptions
server = auth.server
appId = auth.appId
appKey = auth.appKey
}
}
+109
View File
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK 用到的 DTO(对齐 C# SimApiAuthCenterDto / SimApiAuthIamDto)。
*
* 说明:C# 中这些 DTO 是 SimApiAuthCenterDto / SimApiAuthIamDto 的嵌套类;
* 仓颉不支持在类体内声明嵌套类(unexpected class declaration in class body),
* 故拍平为顶层类,语义与字段保持一致。
*
* 序列化/反序列化由 simapi_serialization 反射处理(免标注、免约束)。
*/
package simcu::simapi.authsdk
import std.collection.*
/**
* 应用/Profile 通用项(对齐 C# AppAndProfileItem)。
*/
public class AppAndProfileItem {
public var id: String = ""
public var name: String = ""
public var image: ?String = None
public var description: ?String = None
}
/**
* 安全确认响应(对齐 C# ConfirmResponse)。
* data 用 ?HashMap<String, Any> 对齐 C# Dictionary<string,object>?(任意 JSON 对象)。
*/
public class ConfirmResponse {
public var applicationId: String = ""
public var profileId: String = ""
public var scene: ?String = None
public var data: ?HashMap<String, Any> = None
}
/**
* 登录信息响应(对齐 C# LoginInfoResponse)。
*/
public class LoginInfoResponse {
public var scene: ?String = None
public var data: ?HashMap<String, Any> = None
public var profileId: String = ""
public var name: String = ""
public var image: ?String = None
public var description: ?String = None
}
/**
* 获取授权码响应(对齐 C# GetCodeResponse)。
*/
public class GetCodeResponse {
public var code: String = ""
public var server: String = ""
public var fullUrl: String = ""
public init() {}
public init(code: String, server: String, fullUrl: String) {
this.code = code
this.server = server
this.fullUrl = fullUrl
}
}
/**
* 群组关联项(对齐 C# GroupRelatedItem)。
*/
public class GroupRelatedItem {
public var id: String = ""
public var name: String = ""
public var image: ?String = None
public var description: ?String = None
public var isOwner: Bool = false
public var isAdmin: Bool = false
public var isMember: Bool = false
}
/**
* 群组详情树节点(对齐 C# GroupDetailTreeNodechildren 递归)。
*/
public class GroupDetailTreeNode {
public var id: String = ""
public var name: String = ""
public var image: ?String = None
public var description: ?String = None
public var sort: Int64 = 0
public var children: Array<GroupDetailTreeNode> = []
}
/**
* 权限项(对齐 C# PermissionItem)。
*/
public class PermissionItem {
public var identifier: String = ""
public var name: String = ""
public var group: String = ""
public var description: String = ""
public init() {}
public init(identifier: String, name: String, group: String, description: String) {
this.identifier = identifier
this.name = name
this.group = group
this.description = description
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK/SimApiAuthIam:权限中心远程 SDK。
*/
package simcu::simapi.authsdk
import std.collection.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
/**
* 权限中心远程 SDK(对齐 C# SimApiAuthIam):
* 注册权限点 / 获取权限标识 / 校验权限。
*/
public class SimApiAuthIam {
private let _client: SimApiAuthClient
public init(client: SimApiAuthClient) {
this._client = client
}
/**
* 向 IAM 注册权限(对齐 C# RegisterPermissions)。
*/
public func registerPermissions(permissions: Array<PermissionItem>): Unit {
// 请求体:{"permissions":[{"identifier":...,"name":...,"group":...,"description":...},...]}
var items = ArrayList<Any>()
for (p in permissions) {
var item = HashMap<String, Any>()
item["identifier"] = p.identifier
item["name"] = p.name
item["group"] = p.group
item["description"] = p.description
items.add(item)
}
var body = HashMap<String, Any>()
body["permissions"] = items.toArray()
_client.signQuery<String>("/api/iam/permission/register", body: SimApiUtil.json(Some(body)))
}
/**
* 获取拥有的权限标识数组(对齐 C# GetPermissionOwned)。
*/
public func getPermissionOwned(profileId: String, groupId!: ?String = None): Array<String> {
var body = HashMap<String, Any>()
body["profileId"] = profileId
if (let Some(groupId) <- groupId) {
body["groupId"] = groupId
}
_client.signQuery<Array<String>>("/api/iam/permission/owned", body: SimApiUtil.json(Some(body)))
}
/**
* 检测 profileId 是否有该权限,无权限抛 403(对齐 C# CheckPermission)。
*/
public func checkPermission(profileId: String, permission: String, groupId!: ?String = None): Unit {
var body = HashMap<String, Any>()
body["profileId"] = profileId
body["permission"] = permission
if (let Some(groupId) <- groupId) {
body["groupId"] = groupId
}
let ok = _client.signQuery<Bool>("/api/iam/permission/check", body: SimApiUtil.json(Some(body)))
SimApiError.errorWhen(!ok, code: 403, message: "没有该权限")
}
}
+12 -19
View File
@@ -1,26 +1,26 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 通用请求 DTO(对齐 C# Communications/SimApiBaseRequest.cs)。
*
* 说明:这些类仅作反序列化目标(FromJson<T> / @FromBody),无参构造由编译器自动提供;
* 不声明显式构造器。
*/
package simapi.communications
/**
* 基础请求 DTO。
*/
public class SimApiBaseRequest {}
package simcu::simapi.communications
/**
* 仅包含 Id 的请求。
*/
public class SimApiIdOnlyRequest {
public var id: Int64 = 0
}
/**
* 仅包含 Id 的请求(字符串)。
*/
public class SimApiStringIdOnlyRequest {
public var id: String = ""
public init() {}
public init(id: String) {
this.id = id
}
}
/**
@@ -43,11 +43,4 @@ public class SimApiOneFieldRequest<T> {
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
}
}
+35 -94
View File
@@ -3,35 +3,32 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.communications
package simcu::simapi.communications
import std.collection.*
import stdx.encoding.json.*
import soulsoft_serialization.*
import soulsoft_serialization.macros.*
/**
* 基础响应体:所有接口统一返回该结构。
* HTTP 状态码始终 200,业务错误通过 code 字段表达。
* 序列化/反序列化由 simapi_serialization 反射处理(无需接口/宏)。
*/
@Serialization
public open class SimApiBaseResponse {
public var _code: Int64 = 200
public var _message: String = "成功"
public class SimApiBaseResponse {
public var code: Int64 = 200
public var message: String = "成功"
public init(code: Int64, message: String) {
this._code = code
this._message = message
this.code = code
this.message = message
}
public init(code: Int64) {
this._code = code
this._message = getDefaultMessage(code)
this.code = code
this.message = getDefaultMessage(code)
}
public init() {
this._code = 200
this._message = "成功"
this.code = 200
this.message = "成功"
}
/**
@@ -49,112 +46,56 @@ public open class SimApiBaseResponse {
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。
* 说明:泛型类暂不通过反射序列化,可手动转换为 SimApiResponse。
*/
public class PageResponse<T> {
public var _list: Array<T> = Array<T>()
public var _page: Int64 = 1
public var _count: Int64 = 20
public var _total: Int64 = 0
public var list: Array<T> = Array<T>()
public var page: Int64 = 1
public var count: Int64 = 20
public var total: Int64 = 0
public init() {}
public init(list: Array<T>, page: Int64, count: Int64, total: Int64) {
this._list = list
this._page = page
this._count = count
this._total = total
this.list = list
this.page = page
this.count = count
this.total = total
}
}
/**
* 带数据的动态响应。
* @param T 数据类型。
* 说明:泛型类手写实现 ISerialization<SimApiResponse<T>>serialize + deserialize),
* data 内嵌为对象(对齐 C# SimApiBaseResponse<T>.Data 是 T? 而非字符串)。
* 序列化支持动态结构(HashMap<String,Any> 等经 SimApiJson 内嵌);反序列化要求 T <: ISerialization<T>。
* 序列化/反序列化由 simapi_serialization 反射处理:
* - code/message/data 为普通字段,反射递归
* - data 支持任意 T(免约束)
* 注:泛型嵌套(SimApiResponse<T>)的反射反序列化已验证可用。
*/
public class SimApiResponse<T> <: SimApiBaseResponse & ISerialization<SimApiResponse<T>> where T <: ISerialization<T> {
public var _data: ?T = None
public class SimApiResponse<T> {
public var code: Int64 = 200
public var message: String = "成功"
public var data: ?T = None
public init() {
super()
}
public init() {}
public init(data: T) {
super()
this._data = Some(data)
this.data = Some(data)
}
public init(code: Int64, message: String) {
super(code, message)
this.code = code
this.message = message
}
public init(code: Int64, message: String, data: T) {
super(code, message)
this._data = Some(data)
}
/**
* 序列化为 DataModelcode + message + datadata 为对象)。
*/
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<String, Any> 等):经 SimApiJson 序列化后解析内嵌为对象
let json = SimApiJson.json(Some(data))
dms.add(Field("data", DataModel.fromJson(JsonValue.fromStr(json))))
}
}
dms
}
/**
* 从 DataModel 反序列化:code + message + data(对齐 .NET JsonSerializer.Deserialize<T>)。
*/
public static func deserializeObject(dm: DataModel, options: JsonSerializerOptions): SimApiResponse<T> {
let resp = SimApiResponse<T>()
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
this.code = code
this.message = message
this.data = Some(data)
}
}
+15 -64
View File
@@ -3,11 +3,7 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.communications
import std.collection.*
import stdx.encoding.json.*
import soulsoft_serialization.*
package simcu::simapi.communications
/**
* 带任意对象数据的响应(非泛型版,供响应自动封装使用)。
@@ -15,74 +11,29 @@ import soulsoft_serialization.*
* 与 SimApiResponse<T> 的区别:data 为 Any(运行时类型不定),用于
* SimApiRequestDelegateFactory 派发结果时统一包装 DTO/数组/动态结构。
*
* 序列化规则(对齐 SimApiResponse<T>
* - data 实现了 ISerializable@Serialization DTO、Array<T> 等)→ data 内嵌为对象
* - data 为动态结构(HashMap<String, Any>,不满足泛型 ISerialization 约束)
* → 经 SimApiJson 序列化后解析内嵌为对象(而非字符串)
* 因此 data 在 JSON 中始终是对象/数组/标量,不会是"JSON 字符串"。
* 序列化/反序列化由 simapi_serialization 反射处理
* - code/message/data 为普通字段,反射递归
* - data 为动态结构(HashMap<String, Any>)同样反射支持
*/
public class SimApiDataResponse <: SimApiBaseResponse & ISerialization<SimApiDataResponse> {
public var _data: ?Any = None
public class SimApiDataResponse {
public var code: Int64 = 200
public var message: String = "成功"
public var data: ?Any = None
public init() {
super()
}
public init() {}
public init(data: Any) {
super()
this._data = Some(data)
this.data = Some(data)
}
public init(code: Int64, message: String) {
super(code, message)
this.code = code
this.message = message
}
public init(code: Int64, message: String, data: Any) {
super(code, message)
this._data = Some(data)
}
/**
* 序列化为 DataModelcode + message + datadata 为对象)。
*/
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<String, Any> 等):经 SimApiJson 序列化后解析内嵌为对象
let json = SimApiJson.json(Some(data))
dms.add(Field("data", DataModel.fromJson(JsonValue.fromStr(json))))
}
}
dms
}
/**
* 从 DataModel 反序列化:code + message + datadata 保留原始 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
this.code = code
this.message = message
this.data = Some(data)
}
}
-100
View File
@@ -1,100 +0,0 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* JSON 序列化统一工具(对应 C# SimApiUtil.Json)。
*/
package simapi.communications
import std.collection.*
/**
* JSON 序列化静态工具类。
*
* 说明:序列化核心放在依赖图最底层的 simapi.communications 包,
* SimApiUtil.jsonsimapi.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<Any> <- 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<String, Any> <- 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"
}
}
+17 -145
View File
@@ -4,164 +4,36 @@
* Communications/SimApiLoginItem:登录信息项。
*/
package simapi.communications
package simcu::simapi.communications
import std.collection.*
import soulsoft_serialization.*
/**
* 登录信息项:Token 认证通过后注入请求上下文。
* 对齐 C# SimApiLoginItemId / Type / Meta / ExtracamelCase 输出 id/type/meta/extra)。
* 对齐 C# SimApiLoginItemId / Type / Meta / Extra)。
*
* 说明:因 _extra 为 HashMap<String, Any>Any 不满足 soulsoft 的 ISerialization<V> 约束,
* 无法用 @Serialization 宏),故手动实现 ISerialization<SimApiLoginItem>serialize + deserialize);
* 调用方统一通过 JsonSerializer.serializeObject<T>() / deserializeObject<T>() 使用
* (对齐 .NET JsonSerializer.Serialize / Deserialize)。
* 序列化/反序列化由 simapi_serialization 反射处理(免标注、免约束
* 字段 id/types/meta/extra 全部为受支持类型(String/Array/HashMap)。
*/
public class SimApiLoginItem <: ISerialization<SimApiLoginItem> {
public var _id: String = ""
public var _types: Array<String> = []
public var _meta: HashMap<String, String> = HashMap<String, String>()
public var _extra: HashMap<String, Any> = HashMap<String, Any>()
public class SimApiLoginItem {
public var id: String = ""
public var types: Array<String> = ["user"]
public var meta: HashMap<String, String> = HashMap<String, String>()
public var extra: HashMap<String, Any> = HashMap<String, Any>()
public init() {}
public init(id: String) {
this._id = id
this._types = ["user"]
this._meta = HashMap<String, String>()
this._extra = HashMap<String, Any>()
this.id = id
this.types = ["user"]
this.meta = HashMap<String, String>()
this.extra = HashMap<String, Any>()
}
public init(id: String, types: Array<String>) {
this._id = id
this._types = types
this._meta = HashMap<String, String>()
this._extra = HashMap<String, Any>()
}
/**
* 序列化为 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<string,string>
let metaStruct = DataModelStruct()
for ((k, v) in _meta) {
metaStruct.add(Field(k, DataModelString(v)))
}
dms.add(Field("meta", metaStruct))
// extra: object<string,any>
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<String>()
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<String, Any>()
for (field in dms.getFields()) {
map[field.getName()] = dataModelToAny(field.getData())
}
return map
}
if (let seq: DataModelSeq <- dm) {
let list = ArrayList<Any>()
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()
this.id = id
this.types = types
this.meta = HashMap<String, String>()
this.extra = HashMap<String, Any>()
}
}
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiAuthCenterOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* 认证中心配置(对齐 C# SimApiAuthCenterOptions)。
@@ -36,5 +36,4 @@ public class SimApiAuthCenterOptions {
*/
public var useIam: Bool = false
public init() {}
}
+1 -3
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiDocOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
import std.collection.*
@@ -55,8 +55,6 @@ public class SimApiAuthOption {
* 授权范围。
*/
public var scopes: HashMap<String, String> = HashMap<String, String>()
public init() {}
}
/**
+1 -2
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiExceptionOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
import std.collection.*
@@ -17,5 +17,4 @@ public class SimApiExceptionOptions {
*/
public var skipStatusCodes: HashSet<Int64> = HashSet<Int64>([200, 301, 302])
public init() {}
}
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiHttpClientOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* HTTP 客户端配置。
@@ -13,11 +13,5 @@ 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<String> = []
public init() {}
}
+1 -3
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiJobOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
import std.collection.*
@@ -21,8 +21,6 @@ public class SimApiJobServer {
* 工作线程数(默认 5)。
*/
public var workerNum: Int64 = 5
public init() {}
}
/**
+1 -2
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
import std.collection.*
@@ -112,7 +112,6 @@ public class SimApiOptions {
public var simApiRouteOptions = SimApiRouteOptions()
public var simApiRequestLogOptions = SimApiRequestLogOptions()
public init() {}
// ===== .NET 风格配置回调(对齐 C# ConfigureSimApiXxx(opt => ...) =====
+12 -3
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiRequestLogOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* 请求日志配置。
@@ -16,14 +16,23 @@ public class SimApiRequestLogOptions {
public var showFullHeader: Bool = false
/**
* 是否打印完整的响应体。
* 是否打印完整的响应体(false 时截断到 200 字符,对齐 C#)
*/
public var showFullResponse: Bool = false
/**
* 请求地址是否显示完整 URL(false 时仅显示路径+查询串)。
*/
public var showFullUrl: Bool = true
/**
* 是否在请求行显示运行耗时(如 [POST] (12ms) http://...)。
*/
public var showRunTime: Bool = true
/**
* 请求字段显示最长长度(0 表示不截断)。
*/
public var requestStringLogLength: Int64 = 0
public init() {}
}
+1 -2
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiRouteOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* 路由相关配置(默认值与 C# 一致)。
@@ -25,5 +25,4 @@ public class SimApiRouteOptions {
*/
public var webConfigRoute: ?String = Some("/config")
public init() {}
}
+1 -2
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiStorageOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* 存储相关配置(占位:仓颉版暂未实现 S3/MinIO 存储)。
@@ -16,5 +16,4 @@ public class SimApiStorageOptions {
public var bucket: String = ""
public var serveUrl: String = ""
public init() {}
}
+1 -2
View File
@@ -4,7 +4,7 @@
* 对齐 C# 的 Configurations/SimApiSynapseOptions.cs。
*/
package simapi.configurations
package simcu::simapi.configurations
/**
* MQTT 通信配置(默认值与 C# 一致)。
@@ -47,5 +47,4 @@ public class SimApiSynapseOptions {
*/
public var disableRpcClient: Bool = false
public init() {}
}
+3 -3
View File
@@ -4,11 +4,11 @@
* Controllers/SimApiAuthController:认证相关内置路由。
*/
package simapi.controllers
package simcu::simapi.controllers
import soulsoft_web_mvc.annotations.*
import simapi.communications.*
import simapi.helpers.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
/**
* 认证控制器:退出登录。
+5 -4
View File
@@ -5,18 +5,19 @@
* 提供当前登录信息访问(对齐 C# 的 LoginInfo / LoginToken)。
*/
package simapi.controllers
package simcu::simapi.controllers
import soulsoft_web_http.*
import soulsoft_web_mvc.core.*
import simapi.communications.*
import simapi.helpers.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
import simcu::simapi.interfaces.*
/**
* 基础控制器:所有控制器均继承本控制器。
* 对齐 C# 的 SimApiBaseController[Consumes]/[Produces] JSON + 登录信息)。
*/
public open class SimApiBaseController <: Controller {
public open class SimApiBaseController <: Controller & IBindRequestContext {
/**
* 当前登录信息(需 EnableSimApiAuth;未登录抛 401)。
*/
+7 -20
View File
@@ -4,13 +4,13 @@
* Controllers/SimApiCommonController:通用内置路由。
*/
package simapi.controllers
package simcu::simapi.controllers
import std.collection.*
import soulsoft_web_mvc.annotations.*
import simapi.communications.*
import simapi.configurations.*
import simapi.helpers.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
/**
* 通用控制器:错误反馈、WebConfig、用户信息。
@@ -26,24 +26,11 @@ public class SimApiCommonController <: SimApiBaseController {
/**
* GET /exception/{code}:错误反馈页面(始终注册)。
* 返回 SimApiBaseResponse(已是响应体,原样输出
* 对齐 C# ExceptionHandler:抛 SimApiException,由异常中间件统一输出。
*/
@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<String, Any> {
versionsMap()
}
@HttpPost["versions"]
public func versionsPost(): HashMap<String, Any> {
versionsMap()
public func exceptionHandler(@FromRoute code: Int64): Unit {
SimApiError.error(code: code)
}
/**
+1 -1
View File
@@ -3,7 +3,7 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.exceptions
package simcu::simapi.exceptions
/**
* API 错误捕获异常:携带业务错误码,由异常中间件统一转换为 HTTP 200 + JSON。
-315
View File
@@ -1,315 +0,0 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\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# 的 SimApiExtensionsAddSimApi + 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<IRequestDelegateFactory, SimApiRequestDelegateFactory>()
}
// 自动注册 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<SimApiOptions>(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<T>(),其中间件由 UseMiddleware 创建)
// 认证(DI 自动注入 SimApiOptions
if (options.enableSimApiAuth) {
builder.services.addSingleton<SimApiAuth, SimApiAuth>()
}
// 缓存(DI 自动注入 SimApiOptions
if (options.enableSimApiCache) {
builder.services.addSingleton<SimApiCache, SimApiCache>()
}
// HTTP 客户端(DI 自动注入 SimApiOptions
if (options.enableSimApiHttpClient) {
builder.services.addSingleton<SimApiHttpClient, SimApiHttpClient>()
}
// 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<SimApiOptions>()
let loggerFactory = this.services.getOrThrow<ILoggerFactory>()
// 对齐 C# ILogger<SimApiOptions>:分类名为 SimApiOptions 的全限定名
let logger = loggerFactory.createLogger<SimApiOptions>()
// ===== 基础信息(对齐 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<SimApiAuthCenterMiddleware>
if (options.enableSimApiAuthGate) {
logger.info("开始配置 SimApiAuthGate...")
}
// 认证中间件(对齐 C# builder.UseMiddleware<SimApiAuthMiddleware>()
if (options.enableSimApiAuth) {
logger.info("开始配置 SimApiAuth...")
this.use<SimApiAuthMiddleware>()
}
// 请求日志中间件(对齐 C# builder.UseMiddleware<SimApiRequestLogMiddleware>()
if (options.enableRequestLog) {
logger.info("开始配置 SimApiRequestLog...")
this.use<SimApiRequestLogMiddleware>()
}
// 异常中间件最后挂载(最内层,对齐 C# builder.UseMiddleware<SimApiExceptionMiddleware>()
if (options.enableSimApiException) {
logger.info("开始配置 SimApiException...")
this.use<SimApiExceptionMiddleware>()
}
// 内置路由
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<IServiceProviderIsService>()
if (callSiteFactory.isService<ApplicationPartManager>()) {
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<TypeInfo>): MvcBuilder
}
extend ServiceCollection <: SimApiMvcBuilderExtensions {
/**
* 注册 MVC 服务,并注册 SimApi 内置控制器 + 用户控制器到 ApplicationPartManager。
* 对齐 .NET 的 AddControllers()(含控制器发现)。
* @param controllerTypes 用户控制器类型列表。
* @return MVC 构建器。
*/
public func addControllers(controllerTypes: Array<TypeInfo>): MvcBuilder {
// 调用 soulsoft_web_mvc 的无参 addControllers() 注册 MVC 核心服务
let mvc = this.addControllers()
let types = ArrayList<TypeInfo>()
// SimApi 内置控制器
types.add(TypeInfo.of<SimApiCommonController>())
types.add(TypeInfo.of<SimApiAuthController>())
// 用户控制器
for (t in controllerTypes) {
types.add(t)
}
let part = AssemblyPart("simapi.controllers", types.toArray())
mvc.addApplicationPart(part)
mvc
}
}
@@ -1,145 +0,0 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 自定义 IRequestDelegateFactory:在结果派发时自动封装响应,
* 对齐 C# SimApiResponseFilterIResultFilter)的行为:
* - 返回 SimApiBaseResponse 或其子类 → 原样输出
* - 返回 null/voidUnit)→ SimApiBaseResponse(){code:200, message:成功}
* - 返回 String → SimApiResponse<String>data 为字符串)
* - 返回其他对象 → SimApiResponse<Any>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<MvcOptions>, 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<IOptions<ApiBehaviorOptions>>()
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<Any>(result).invoke(context)
} else if (let result: String <- actionResult) {
// String → SimApiResponse<String>data 为字符串)
ObjectResult<Any>(SimApiResponse<String>(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<String,Any> 等动态结构 → SimApiJson 序列化后解析内嵌),不会变成 JSON 字符串
ObjectResult<Any>(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
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.helpers
import std.collection.*
import std.io.*
import simcu::serialization.*
import soulsoft_web_http.*
import simcu::simapi.communications.*
import simcu::simapi.exceptions.*
import simcu::simapi.interfaces.*
/**
* AES body 请求({"data": "密文"},对齐 C# SimApiOneFieldRequest<string>)。
*/
public class AesBodyRequest {
public var data: String = ""
}
/**
* 服务端 AES body 解密校验器(对齐 C# ModelBinders/AesBodyModelBinder)。
*
* 仓颉无 ModelBinder 机制,按项目惯例由控制器在方法开头调用:
* let jsonStr = SimApiAesBodyChecker.decryptBody(context, provider)
* let request = JsonSerializer.Deserialize<XxxRequest>(jsonStr)
* 或标注 @AesBody 注解自动执行(SimApiRequestDelegateFactory)。
*
* 流程(与 C# 一致):
* 1. 读取 body 并反序列化为 {"data": "密文"}
* 2. 校验 Data 非空
* 3. 提取 appIdQuery/Header
* 4. provider.getKey(appId) 获取密钥
* 5. SimApiAesUtil.decrypt 解密得到明文 JSON 字符串
* 返回解密后的 JSON 字符串,由控制器按目标类型反序列化。
*/
public class SimApiAesBodyChecker {
private init() {}
/**
* 解密请求体,返回明文 JSON 字符串。
* @param context 当前请求上下文。
* @param provider AES 密钥提供器。
* @return 解密后的 JSON 字符串。
*/
public static func decryptBody(context: HttpContext, provider: AesBodyProviderBase): String {
// 1. 读取 body
let body = readBody(context)
if (body.isEmpty()) {
SimApiError.error(code: 400, message: "请求体不能为空")
}
// 2. 反序列化 {"data": "密文"}
let req = JsonSerializer.Deserialize<AesBodyRequest>(body)
if (req.data.isEmpty()) {
SimApiError.error(code: 400, message: "请求体缺少密文Data字段")
}
// 3. 提取 appId
var appId: ?String = None
if (let Some(name) <- provider.appIdName) {
if (!name.isEmpty()) {
appId = getParam(context, name)
if (appId == None || appId == Some("")) {
SimApiError.error(code: 400, message: "未找到${name}")
}
}
}
// 4. 获取密钥
let key = provider.getKey(appId)
if (key == None || key == Some("")) {
SimApiError.error(code: 400, message: "获取密钥失败(应用不存在或密钥未配置)")
}
// 5. 解密
let jsonStr = SimApiAesUtil.decrypt(req.data, key.getOrThrow())
if (jsonStr.isEmpty()) {
SimApiError.error(code: 400, message: "解密失败")
}
jsonStr
}
private static func readBody(context: HttpContext): String {
try {
context.request.enableBuffering()
var buffer = Array<Byte>(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)
}
// 重置流位置,供后续业务读取
if (let seekable: Seekable <- context.request.body) {
seekable.seek(SeekPosition.Begin(0))
}
sb.toString()
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "读取请求体失败: ${ex.message}")
}
""
}
private static func getParam(context: HttpContext, name: String): ?String {
let q = context.request.query.get(name)
if (q != None && q != Some("")) {
return q
}
context.request.headers.get(name)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.random.*
+109 -31
View File
@@ -3,24 +3,36 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.collection.concurrent.*
import std.convert.*
import stdx.encoding.json.*
import soulsoft_serialization.*
import std.time.*
import simcu::serialization.*
import redis.client.*
import simapi.communications.*
import simapi.configurations.*
import simapi.exceptions.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.exceptions.*
/**
* 认证助手:基于 Header Token 的登录态管理。
* 支持两种存储模式:
* - Redis 模式:配置了 RedisConfiguration 时使用,支持多实例共享。
* - InMemory 模式:未配置 Redis 时自动使用,重启后登录态丢失。
* - Redis 模式:配置了 RedisConfiguration 时使用,支持多实例共享(可带密码/DB 索引)
* - InMemory 模式:未配置 Redis 时自动使用,登录态带过期时间,重启后丢失。
*/
/// InMemory 存储项:登录信息 JSON + 过期时间(epoch 毫秒,0 表示不过期)
private struct TokenEntry {
var json: String
var expireAt: Int64
public init(json: String, expireAt: Int64) {
this.json = json
this.expireAt = expireAt
}
}
public class SimApiAuth {
private static let tokenCachePrefix = "SimApi:Auth:Token:"
private static let tokenSetCachePrefix = "SimApi:Auth:User:"
@@ -29,8 +41,8 @@ public class SimApiAuth {
private var _redisHost: String = ""
private var _redisPort: UInt16 = 6379
// InMemory 模式:token → 登录信息 JSON
private let _tokenStore = ConcurrentHashMap<String, String>()
// InMemory 模式:token → 登录信息(含过期时间,epoch 毫秒;0 表示不过期)
private let _tokenStore = ConcurrentHashMap<String, TokenEntry>()
// InMemory 模式:userId → token 集合
private let _userTokens = ConcurrentHashMap<String, HashSet<String>>()
@@ -41,10 +53,24 @@ public class SimApiAuth {
public init(options: SimApiOptions) {
let redisConfiguration = options.redisConfiguration
if (!redisConfiguration.isEmpty()) {
let (host, port) = parseRedisConfig(redisConfiguration)
let (host, port, password, db) = parseRedisConfig(redisConfiguration)
_redisHost = host
_redisPort = port
_redis = Some(RedisClient(host, port))
let client = if (password.isEmpty()) {
// autoHello=false:跳过 HELLO 3 协商(Redis 8.x 的 RESP3 响应含 modules 等嵌套结构,
// redis-client 库解析偶发失败),直接用 RESP2 协议
RedisClient(host, port, autoHello: false)
} else {
RedisClient(host, port, autoHello: false, authPassword: Some(password))
}
// 指定 DB 索引(redis 客户端无 select 方法,直接执行 SELECT 命令)
if (db > 0) {
try {
client.executeString(["SELECT", db.toString()])
} catch (_: Exception) {
}
}
_redis = Some(client)
}
}
@@ -58,7 +84,7 @@ public class SimApiAuth {
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 setKey = "${tokenSetCachePrefix}${loginItem.id}"
let json = loginItemJson(loginItem)
if (let Some(redis) <- _redis) {
@@ -66,11 +92,11 @@ public class SimApiAuth {
redis.sadd(setKey, [Blob.fromUtf8(newToken)])
redis.expire(setKey, expireSeconds)
} else {
_tokenStore[newToken] = json
var tokens = _userTokens.get(loginItem._id)
_tokenStore[newToken] = TokenEntry(json, nowMillis() + expireSeconds * 1000)
var tokens = _userTokens.get(loginItem.id)
if (tokens == None) {
tokens = HashSet<String>()
_userTokens[loginItem._id] = tokens.getOrThrow()
_userTokens[loginItem.id] = tokens.getOrThrow()
}
tokens.getOrThrow().add(newToken)
}
@@ -87,9 +113,19 @@ public class SimApiAuth {
let tokenKey = "${tokenCachePrefix}${token}"
let json = loginItemJson(loginItem)
if (let Some(redis) <- _redis) {
// 保留原过期时间(对齐 C# update 不刷新 TTL):先读旧 TTL,SET 后重新续期
let ttl = redis.ttl(tokenKey)
redis.set(tokenKey, Blob.fromUtf8(json))
if (ttl > 0) {
redis.expire(tokenKey, ttl)
}
} else {
_tokenStore[token] = json
// 保留原过期时间(对齐 C# update 不刷新 TTL
let expireAt = match (_tokenStore.get(token)) {
case Some(entry) => entry.expireAt
case None => 0
}
_tokenStore[token] = TokenEntry(json, expireAt)
}
return token
}
@@ -108,14 +144,33 @@ public class SimApiAuth {
case None => None
}
} else {
json = _tokenStore.get(token)
json = match (_tokenStore.get(token)) {
case Some(entry) =>
// InMemory 过期检查:超过 expireAt 则移除并视为无效
if (entry.expireAt > 0 && entry.expireAt < nowMillis()) {
_tokenStore.remove(token)
None
} else {
Some(entry.json)
}
case None => None
}
}
return match (json) {
case Some(j) => Some(parseLoginItem(j))
case Some(j) => parseLoginItemSafe(j)
case None => None
}
}
/// 解析登录信息;JSON 无效/非对象时返回 None(视为 token 无效,对齐 C# 返回 null
private static func parseLoginItemSafe(json: String): ?SimApiLoginItem {
try {
Some(JsonSerializer.Deserialize<SimApiLoginItem>(json))
} catch (_: Exception) {
None
}
}
/**
* 获取某用户全部登录信息。
* @param userId 用户 ID。
@@ -141,7 +196,7 @@ public class SimApiAuth {
public func logout(token: String): Unit {
let item = getLogin(token)
if (let Some(item) <- item) {
removeTokenOfUser(item._id, token)
removeTokenOfUser(item.id, token)
}
let tokenKey = "${tokenCachePrefix}${token}"
if (let Some(redis) <- _redis) {
@@ -202,21 +257,44 @@ public class SimApiAuth {
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]))
/// 解析 Redis 连接串,支持:host:port | host:port,password=xxx | host:port,password=xxx,db=2
private static func parseRedisConfig(config: String): (String, UInt16, String, Int64) {
var host = "127.0.0.1"
var port = 6379u16
var password = ""
var db: Int64 = 0
let segments = config.split(",")
let hp = segments[0].split(":")
if (hp.size == 2) {
host = hp[0]
port = UInt16.parse(hp[1])
} else {
host = config
}
return (config, 6379u16)
for (i in 1..segments.size) {
let seg = segments[i].trimAscii()
match (seg.indexOf("=")) {
case Some(idx) =>
let key = seg[0..idx].trimAscii().toAsciiLower()
let value = seg[idx + 1..].trimAscii()
match (key) {
case "password" | "pwd" => password = value
case "db" | "database" | "defaultdatabase" => db = Int64.parse(value)
case _ => ()
}
case None => ()
}
}
(host, port, password, db)
}
/// 当前时间(epoch 毫秒)
private static func nowMillis(): Int64 {
DateTime.nowUTC().toUnixTimeStamp().toMilliseconds()
}
private static func loginItemJson(item: SimApiLoginItem): String {
// 统一 JSON 序列化:对齐 .NET JsonSerializer.Serialize(item)
JsonSerializer.serializeObject<SimApiLoginItem>(item)
}
private static func parseLoginItem(json: String): SimApiLoginItem {
// 统一 JSON 反序列化:对齐 .NET JsonSerializer.Deserialize<SimApiLoginItem>(json)
JsonSerializer.deserializeObject<SimApiLoginItem>(json)
JsonSerializer.Serialize(item)
}
}
+97 -21
View File
@@ -3,24 +3,38 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.collection.concurrent.*
import std.convert.*
import std.time.*
import redis.client.*
import simapi.configurations.*
import simapi.exceptions.*
import simcu::serialization.*
import simcu::simapi.configurations.*
import simcu::simapi.exceptions.*
/// InMemory 存储项:缓存值 + 过期时间(epoch 毫秒,0 表示不过期)
private struct CacheEntry {
var value: String
var expireAt: Int64
public init(value: String, expireAt: Int64) {
this.value = value
this.expireAt = expireAt
}
}
/**
* 缓存助手:Key 自动加前缀 "SimApi:Cache:"。
* 存储后端与 SimApiAuth 一致:配置了 Redis 用 Redis,否则 InMemory。
* 存储后端与 SimApiAuth 一致:配置了 Redis 用 Redis(可带密码/DB 索引),否则 InMemory。
* InMemory 模式同样支持过期(对齐 C# DistributedCache 的过期语义)。
*/
public class SimApiCache {
private static let prefix = "SimApi:Cache:"
private var _redis: ?RedisClient = None
private let _store = ConcurrentHashMap<String, String>()
private let _store = ConcurrentHashMap<String, CacheEntry>()
/**
* 创建缓存(依赖注入 SimApiOptions)。
@@ -29,8 +43,21 @@ public class SimApiCache {
public init(options: SimApiOptions) {
let redisConfiguration = options.redisConfiguration
if (!redisConfiguration.isEmpty()) {
let (host, port) = parseRedisConfig(redisConfiguration)
_redis = Some(RedisClient(host, port))
let (host, port, password, db) = parseRedisConfig(redisConfiguration)
let client = if (password.isEmpty()) {
// autoHello=false:跳过 HELLO 3 协商(Redis 8.x 的 RESP3 响应含 modules 等嵌套结构,
// redis-client 库解析偶发失败),直接用 RESP2 协议
RedisClient(host, port, autoHello: false)
} else {
RedisClient(host, port, autoHello: false, authPassword: Some(password))
}
if (db > 0) {
try {
client.executeString(["SELECT", db.toString()])
} catch (_: Exception) {
}
}
_redis = Some(client)
}
}
@@ -38,17 +65,19 @@ public class SimApiCache {
* 设置缓存。
* @param key 缓存键。
* @param value 缓存值(不能为 null)。
* @param expireSeconds 过期秒数(可选)。
* @param expireSeconds 过期秒数(可选<=0 表示不过期)。
*/
public func set(key: String, value: String, expireSeconds!: Int64 = -1): Unit {
public func set(key: String, value: Any, expireSeconds!: Int64 = -1): Unit {
let json = SimApiUtil.json(Some(value))
if (let Some(redis) <- _redis) {
if (expireSeconds > 0) {
redis.set("${prefix}${key}", Blob.fromUtf8(value), ex: Some(expireSeconds))
redis.set("${prefix}${key}", Blob.fromUtf8(json), ex: Some(expireSeconds))
} else {
redis.set("${prefix}${key}", Blob.fromUtf8(value))
redis.set("${prefix}${key}", Blob.fromUtf8(json))
}
} else {
_store["${prefix}${key}"] = value
let expireAt = if (expireSeconds > 0) { nowMillis() + expireSeconds * 1000 } else { 0 }
_store["${prefix}${key}"] = CacheEntry(json, expireAt)
}
}
@@ -67,27 +96,74 @@ public class SimApiCache {
* 缓存 Key 是否存在。
*/
public func hasKey(key: String): Bool {
get(key) != None
getString(key) != None
}
/**
* 获取 string 类型缓存
* 获取特定类型缓存(对齐 C# Get<T>:从 JSON 反序列化,使用 simapi_serialization
*/
public func get(key: String): ?String {
public func get<T>(key: String): ?T {
match (getString(key)) {
case Some(json) => Some(JsonSerializer.Deserialize<T>(json))
case None => None
}
}
/**
* 获取 string 类型缓存(对应 C# Get(string)Cangjie 不支持按泛型重载,故拆分为 getString/get<T>)。
*/
public func getString(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}")
return match (_store.get("${prefix}${key}")) {
case Some(entry) =>
if (entry.expireAt > 0 && entry.expireAt < nowMillis()) {
_store.remove("${prefix}${key}")
None
} else {
Some(entry.value)
}
case None => None
}
}
private static func parseRedisConfig(config: String): (String, UInt16) {
let parts = config.split(":")
if (parts.size == 2) {
return (parts[0], UInt16.parse(parts[1]))
/// 解析 Redis 连接串,支持:host:port | host:port,password=xxx | host:port,password=xxx,db=2
private static func parseRedisConfig(config: String): (String, UInt16, String, Int64) {
var host = "127.0.0.1"
var port = 6379u16
var password = ""
var db: Int64 = 0
let segments = config.split(",")
let hp = segments[0].split(":")
if (hp.size == 2) {
host = hp[0]
port = UInt16.parse(hp[1])
} else {
host = config
}
return (config, 6379u16)
for (i in 1..segments.size) {
let seg = segments[i].trimAscii()
match (seg.indexOf("=")) {
case Some(idx) =>
let key = seg[0..idx].trimAscii().toAsciiLower()
let value = seg[idx + 1..].trimAscii()
match (key) {
case "password" | "pwd" => password = value
case "db" | "database" | "defaultdatabase" => db = Int64.parse(value)
case _ => ()
}
case None => ()
}
}
(host, port, password, db)
}
/// 当前时间(epoch 毫秒)
private static func nowMillis(): Int64 {
DateTime.nowUTC().toUnixTimeStamp().toMilliseconds()
}
}
+53 -2
View File
@@ -9,12 +9,13 @@
* 过滤出继承 Controller 的类型(含子包)
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.core.*
import std.reflect.*
import soulsoft_web_mvc.core.*
import simcu::simapi.interfaces.*
/**
* 控制器自动扫描器:从调用栈定位调用者包,枚举该包(含子包)中继承 Controller 的类型。
@@ -33,6 +34,18 @@ public class SimApiControllerScanner {
result.toArray()
}
/**
* 扫描调用者包及其所有子包中 ISimApiAuthChecker 的实现类。
* 对齐 C# AddSimApi 中遍历调用者程序集 AddScoped 注册 checker 的机制。
* @return 找到的 checker 实现类型列表(不含抽象类型与接口本身)。
*/
public static func scanAuthCheckers(): Array<TypeInfo> {
let callerPackage = getCallerPackage()
var result = ArrayList<TypeInfo>()
collectImplementations(callerPackage, TypeInfo.of<ISimApiAuthChecker>(), result)
result.toArray()
}
/**
* 获取调用者(应用)包名:遍历栈帧,跳过 simapi/soulsoft/std 等框架包,
* 返回第一个应用包的 declaringClass(对齐 C# 通过 StackTrace 找调用程序集)。
@@ -44,13 +57,16 @@ public class SimApiControllerScanner {
let st = ex.getStackTrace()
for (el in st) {
let name = el.declaringClass
// 跳过本类及框架包
// 跳过本类及框架包(兼容旧包名 simapi 与新包名 simcu::simapi
if (name.isEmpty()) {
continue
}
if (name.startsWith("simapi.") || name == "simapi") {
continue
}
if (name.startsWith("simcu::simapi.") || name == "simcu::simapi") {
continue
}
if (name.startsWith("soulsoft_") || name.startsWith("std.") || name.startsWith("stdx.")) {
continue
}
@@ -101,4 +117,39 @@ public class SimApiControllerScanner {
}
false
}
/// 收集指定包及其子包中实现指定接口的非抽象类
private static func collectImplementations(packageName: String, interfaceType: TypeInfo,
result: ArrayList<TypeInfo>): Unit {
if (packageName.isEmpty()) {
return
}
try {
let info = PackageInfo.get(packageName)
for (ti in info.typeInfos) {
if (isImplementation(ti, interfaceType)) {
result.add(ti)
}
}
for (sub in info.subPackages) {
collectImplementations("${packageName}.${sub.name}", interfaceType, result)
}
} catch (_: Exception) {
// 包不存在时跳过
}
}
/// 判断类型是否为接口的非抽象实现类
private static func isImplementation(typeInfo: TypeInfo, interfaceType: TypeInfo): Bool {
if (let classTypeInfo: ClassTypeInfo <- typeInfo) {
if (classTypeInfo.isAbstract()) {
return false
}
if (typeInfo == interfaceType) {
return false
}
return typeInfo.isSubtypeOf(interfaceType)
}
false
}
}
+52 -7
View File
@@ -1,11 +1,59 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*
* 错误抛出:提供「顶层函数」+「SimApiError 类」两种写法(对齐 C# using static SimApi.Helpers.SimApiError)。
* - 顶层函数:import simcu::simapi.helpers.* 后可直接 error(400) / errorWhen(...),无需前缀
* - SimApiError.error(...):旧写法,保留兼容
*/
package simapi.helpers
package simcu::simapi.helpers
import simapi.exceptions.*
import simcu::simapi.exceptions.*
// ===== 顶层函数(推荐用法:直接 error(400),对齐 C# using static =====
/**
* 直接抛错。
* @param code 错误代码,默认 500。
* @param message 错误描述,默认空(由 code 自动带取描述)。
*/
public func error(code!: Int64 = 500, message!: String = ""): Unit {
SimApiError.error(code: code, message: message)
}
/**
* 条件为 true 时抛错。
* @param condition 检测条件。
* @param code 错误代码,默认 400。
* @param message 错误描述。
*/
public func errorWhen(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit {
SimApiError.errorWhen(condition, code: code, message: message)
}
/**
* 条件为 true 时抛错(别名)。
*/
public func errorWhenTrue(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit {
SimApiError.errorWhenTrue(condition, code: code, message: message)
}
/**
* 条件为 false 时抛错。
*/
public func errorWhenFalse(condition: Bool, code!: Int64 = 400, message!: String = ""): Unit {
SimApiError.errorWhenFalse(condition, code: code, message: message)
}
/**
* 给定的可选值为 None 时抛错。
*/
public func errorWhenNull(condition: ?Any, code!: Int64 = 404, message!: String = ""): Unit {
SimApiError.errorWhenNull(condition, code: code, message: message)
}
// ===== 兼容门面(旧写法 SimApiError.error(...) 仍可用,内部为真实实现) =====
/**
* 错误抛出辅助类:所有业务错误统一通过这里抛出 SimApiException。
@@ -51,14 +99,11 @@ public class SimApiError {
/**
* 给定的可选值为 None 时抛错。
* @param condition 检测的可选值。
* @param code 错误代码,默认 404。
* @param message 错误描述。
*/
public static func errorWhenNone(condition: ?Any, code!: Int64 = 404, message!: String = ""): Unit {
public static func errorWhenNull(condition: ?Any, code!: Int64 = 404, message!: String = ""): Unit {
match (condition) {
case None => error(code: code, message: message)
case _ => ()
}
}
}
}
+63 -40
View File
@@ -3,27 +3,26 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.io.*
import stdx.net.http.*
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.*
import simcu::serialization.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.exceptions.*
/**
* HTTP 客户端:用于调用其他带签名/AES 的 SimApi 服务。
* 对齐 C# 的 SimApi.Helpers.SimApiHttpClient
* - 内部使用 soulsoft_net_http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient
* - 内部使用 stdx.net.http 的 HttpClient(等价 .NET 的 System.Net.Http.HttpClient
* - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String
* @param T 响应 data 的数据类型(需实现 ISerialization<T>,如 SimApiLoginItem、String、Int64 等)。
* @param T 响应 data 的数据类型(任意类,simapi_serialization 反射反序列化)。
*/
public class SimApiHttpClient {
public open class SimApiHttpClient {
public var server: String
public var appId: String
public var appKey: String
@@ -38,11 +37,6 @@ public class SimApiHttpClient {
server = httpOptions.server
appId = httpOptions.appId
appKey = httpOptions.appKey
signName = httpOptions.signName
timestampName = httpOptions.timestampName
nonceName = httpOptions.nonceName
appIdName = httpOptions.appIdName
signFields = httpOptions.signFields
}
/**
@@ -53,7 +47,7 @@ public class SimApiHttpClient {
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func signQuery<T>(url: String, body!: String = "", queries!: HashMap<String, String> = HashMap<String, String>()): T where T <: ISerialization<T> {
public func signQuery<T>(url: String, body!: String = "", queries!: HashMap<String, String> = HashMap<String, String>()): T {
var queryUrl = StringBuilder()
for (field in signFields) {
queryUrl.append("${field}=")
@@ -85,13 +79,13 @@ public class SimApiHttpClient {
* @param body 请求体 JSON 字符串。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesQuery<T>(url: String, body: String): T where T <: ISerialization<T> {
public func aesQuery<T>(url: String, body: String): T {
var target = "${server}${url}"
if (let Some(name) <- appIdName) {
target = "${target}?${name}=${appId}"
}
let encrypted = aesEncrypt(body)
let req = "{\"data\":\"${encrypted}\"}"
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
return query<T>(target, req)
}
@@ -103,39 +97,40 @@ public class SimApiHttpClient {
* @param queries 额外查询参数(可选)。
* @return 响应 data 字段反序列化后的 T。
*/
public func aesSignQuery<T>(url: String, body: String, queries!: HashMap<String, String> = HashMap<String, String>()): T where T <: ISerialization<T> {
public func aesSignQuery<T>(url: String, body: String, queries!: HashMap<String, String> = HashMap<String, String>()): T {
let encrypted = aesEncrypt(body)
let req = "{\"data\":\"${encrypted}\"}"
let req = SimApiUtil.json(Some(SimApiOneFieldRequest<String>(encrypted)))
return signQuery<T>(url, body: req, queries: queries)
}
/**
* 发起 POST 请求并反序列化 SimApiBaseResponse<T>,返回 data 字段。
* 发起 POST 请求并反序列化 SimApiResponse<T>,返回 data 字段。
* 对齐 C# Query<T>
* ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync<SimApiBaseResponse<T>> → ErrorWhen(Code != 200) → return Data。
* ErrorWhenFalse(IsSuccessStatusCode) → ReadFromJsonAsync<SimApiResponse<T>> → ErrorWhen(Code != 200) → return Data。
* 注意:必须 noProxy(),否则会走系统代理(192.168.0.250:8118)导致连接被拒。
* 反序列化使用 simapi_serializationDeserialize<T> 免约束)。
*/
private func query<T>(url: String, body: String): T where T <: ISerialization<T> {
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)
}
private func query<T>(url: String, body: String): T {
let client = ClientBuilder().
noProxy().
tlsConfig(buildTlsConfig(url)).
readTimeout(Duration.second * 30).
build()
try {
let request = HttpRequestMessage(NetHttpMethod.Post, url)
request.content = JsonContent.create(body)
let request = HttpRequestBuilder().
post().
url(url).
header("Content-Type", "application/json").
body(body).
build()
let response = client.send(request)
try {
SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode, message: "HTTP ERROR: ${response.statusCode}")
let result = response.content.readFromJson<SimApiResponse<T>>()
SimApiError.errorWhen(result._code != 200, code: result._code, message: result._message)
return result._data.getOrThrow()
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "HTTP ERROR: ${response.status}")
let json = readBodyText(response.body)
let result = JsonSerializer.Deserialize<SimApiResponse<T>>(json)
SimApiError.errorWhen(result.code != 200, code: result.code, message: result.message)
return result.data.getOrThrow()
} finally {
response.close()
}
@@ -144,6 +139,34 @@ public class SimApiHttpClient {
}
}
/// 2xx 视为成功
private static func isSuccess(status: UInt16): Bool {
status >= 200 && status < 300
}
/// 读取响应体 InputStream 为字符串
private static func readBodyText(body: InputStream): String {
var buffer = Array<Byte>(4096, repeat: 0)
var sb = StringBuilder()
var read = body.read(buffer)
while (read > 0) {
sb.appendFromUtf8(buffer.slice(0, read))
read = body.read(buffer)
}
sb.toString()
}
/// 构建 TLS 配置:信任所有证书 + SNI 域名
private static func buildTlsConfig(url: String): TlsClientConfig {
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
let host = extractHost(url)
if (!host.isEmpty()) {
tls.serverName = Some(host)
}
tls
}
private func aesEncrypt(plain: String): String {
// 对齐 C#SimApiAesUtil.Encrypt(plain, AppKey)AES-256-CBC + PKCS7Base64(IV + 密文)
SimApiAesUtil.encrypt(plain, appKey)
+378
View File
@@ -0,0 +1,378 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 自定义 IRequestDelegateFactory:在结果派发时自动封装响应,
* 对齐 C# SimApiResponseFilterIResultFilter)的行为:
* - 返回 SimApiBaseResponse 或其子类 → 原样输出
* - 返回 null/voidUnit)→ SimApiBaseResponse(){code:200, message:成功}
* - 返回 String → SimApiResponse<String>data 为字符串)
* - 返回其他对象 → SimApiResponse<Any>data 为对象)
*
* 注册方式:必须在 soulsoft addControllers() 之前注册(tryAddSingleton 先到先得)。
*/
package simcu::simapi.helpers
import std.collection.*
import std.io.*
import std.reflect.*
import stdx.encoding.json.*
import stdx.serialization.serialization.*
import soulsoft_web_http.*
import soulsoft_web_mvc.annotations.*
import soulsoft_web_mvc.core.*
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 simcu::serialization.*
import simcu::simapi.annotations.{SimApiAuth as SimApiAuthAttribute, OriginResponse, SimApiSign, AesBody}
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.interfaces.*
/**
* 自定义请求委托工厂:接管 soulsoft 的 ControllerRequestDelegateFactory
* 在结果派发时自动封装响应(对齐 C# SimApiResponseFilter)。
*/
public class SimApiRequestDelegateFactory <: IRequestDelegateFactory {
private let _mvcOptions: MvcOptions
private let _modelBinder: IActionModelBinder
public init(mvcOptions: IOptions<MvcOptions>, 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()
checkSimApiAuth()
checkSimApiSign()
// 预读并缓存请求体(body 流不可重读;若请求日志中间件已读,直接用其缓存)
if (!context.items.contains(BODY_CACHE_KEY)) {
context.items[BODY_CACHE_KEY] = readBody()
}
let modelBindingContext = ActionBindingContext(context, actionDescriptor.actionFunction.parameters)
// 自定义绑定:无注解参数(FromBody)用 simapi_serialization 反序列化,
// 显式注解参数(Query/Form/Route/Header/Services)委托 soulsoft binder。
let boundParameters = bindParameters(modelBindingContext)
if (!modelBindingContext.modelState.isValid) {
handleInvalidModelState(modelBindingContext)
} else {
let actionResult = actionDescriptor.actionFunction.apply(controller, boundParameters)
dispatchResult(actionResult)
}
}
/// 请求体缓存键(body 流不可重读,读一次后缓存)
private static let BODY_CACHE_KEY = "SimApi:BodyCache"
/// 绑定全部参数:FromBody 自实现(simapi_serialization),其余委托 soulsoft
private func bindParameters(context: ActionBindingContext): Array<Any> {
let params = context.parameters
if (params.size == 0) {
return []
}
let bound = Array<Any>(params.size, repeat: ())
// 存在显式参数时才委托 soulsoft 绑定(Query/Form/Route/Header/Services
var hasExplicit = false
for (parameter in params) {
if (isExplicitlyBound(parameter)) {
hasExplicit = true
break
}
}
let soulsoftBound = if (hasExplicit) { modelBinder.bind(context) } else { Array<Any>(params.size, repeat: ()) }
for ((index, parameter) in params |> enumerate) {
if (isExplicitlyBound(parameter)) {
// Query/Form/Route/Header/Services → soulsoft
bound[index] = soulsoftBound[index]
} else if (let Some(aes) <- parameter.findAnnotation<AesBody>()) {
// @AesBody → 解密 body 后按参数类型反序列化(对齐 C# AesBodyModelBinder
bound[index] = bindAesBody(context, parameter, aes)
} else {
// FromBody → simapi_serialization 按运行时类型反序列化(免 @Serialization 宏)
bound[index] = bindFromBody(context, parameter)
}
}
bound
}
/// 参数是否显式指定绑定源(FromQuery/FromForm/FromRoute/FromHeader/FromServices
private func isExplicitlyBound(parameter: ParameterInfo): Bool {
parameter.findAnnotation<FromQuery>().isSome() ||
parameter.findAnnotation<FromForm>().isSome() ||
parameter.findAnnotation<FromRoute>().isSome() ||
parameter.findAnnotation<FromHeader>().isSome() ||
parameter.findAnnotation<FromServices>().isSome()
}
/// 从请求体反序列化(simapi_serialization 按运行时类型,DTO 免标注)
private func bindFromBody(context: ActionBindingContext, parameter: ParameterInfo): Any {
let body = match (context.httpContext.items.get(BODY_CACHE_KEY)) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
if (body.isEmpty()) {
SimApiError.error(code: 400, message: "请求体不能为空")
}
try {
return JsonSerializer.Deserialize(parameter.typeInfo, body)
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "请求体反序列化失败: ${ex.message}")
}
()
}
/// @AesBody 参数绑定:解密 body 后按参数类型反序列化(对齐 C# AesBodyModelBinder
private func bindAesBody(context: ActionBindingContext, parameter: ParameterInfo, aes: AesBody): Any {
// 1. 从 DI 解析 keyProviderAesBodyProviderBase 实现)
let provider = resolveAesProvider(aes.keyProvider)
// 2. 读取并解密 bodySimApiAesBodyChecker.decryptBody 内部读取原始 body 流)
let plain = SimApiAesBodyChecker.decryptBody(context.httpContext, provider)
// 3. 按参数类型反序列化明文 JSON
try {
return JsonSerializer.Deserialize(parameter.typeInfo, plain)
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "AES body 反序列化失败: ${ex.message}")
}
()
}
/// 从 DI 解析 AesBodyProviderBase 实现(注解未指定类型名时返回默认空实现)
private func resolveAesProvider(keyProvider: String): AesBodyProviderBase {
if (keyProvider.isEmpty()) {
return AesBodyProviderBase()
}
var typeInfo: ?TypeInfo = None
try {
typeInfo = Some(TypeInfo.get(keyProvider))
} catch (_: Exception) {
SimApiError.error(code: 400, message: "未找到 AES 密钥提供器 ${keyProvider}")
}
let instance = context.services.getOrThrow(typeInfo.getOrThrow())
if (let p: AesBodyProviderBase <- instance) {
return p
}
SimApiError.error(code: 400, message: "密钥提供器 ${keyProvider} 未实现 AesBodyProviderBase")
AesBodyProviderBase()
}
/// 检查 @SimApiSign 注解并执行验签(对齐 C# SimApiSignAttribute.OnActionExecuting
private func checkSimApiSign() {
var sign: ?SimApiSign = None
for (item in actionDescriptor.endpointMetadata) {
if (let s: SimApiSign <- item) {
sign = Some(s)
break
}
}
if (let Some(sign) <- sign) {
// 1. 从 DI 解析 keyProviderSimApiSignProviderBase 实现)
let provider = resolveSignProvider(sign.keyProvider)
// 2. 解析缓存(nonce 去重;DI 有 SimApiCache 则用)
var cache: ?SimApiCache = None
try {
cache = Some(context.services.getOrThrow<SimApiCache>())
} catch (_: Exception) {
// 未注册缓存 → 跳过 nonce 去重
}
// 3. 执行验签
SimApiSignChecker.verify(context, provider, cache)
}
}
/// 从 DI 解析 SimApiSignProviderBase 实现(注解未指定类型名时返回默认空实现)
private func resolveSignProvider(keyProvider: String): SimApiSignProviderBase {
if (keyProvider.isEmpty()) {
return SimApiSignProviderBase()
}
var typeInfo: ?TypeInfo = None
try {
typeInfo = Some(TypeInfo.get(keyProvider))
} catch (_: Exception) {
SimApiError.error(code: 400, message: "未找到签名提供器 ${keyProvider}")
}
let instance = context.services.getOrThrow(typeInfo.getOrThrow())
if (let p: SimApiSignProviderBase <- instance) {
return p
}
SimApiError.error(code: 400, message: "签名提供器 ${keyProvider} 未实现 SimApiSignProviderBase")
SimApiSignProviderBase()
}
/// 读取并重置请求体流(供后续业务读取)
private func readBody(): String {
try {
context.request.enableBuffering()
var buffer = Array<Byte>(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 text = sb.toString()
if (let seekable: Seekable <- context.request.body) {
seekable.seek(SeekPosition.Begin(0))
}
text
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "读取请求体失败: ${ex.message}")
}
""
}
/// 检查 @SimApiAuth 注解并执行鉴权(对齐 C# SimApiAuthAttribute.OnActionExecuting):
/// 未登录 401 → 遍历执行 ISimApiAuthChecker → 类型权限 403
private func checkSimApiAuth() {
var auth: ?SimApiAuthAttribute = None
for (item in actionDescriptor.endpointMetadata) {
if (let a: SimApiAuthAttribute <- item) {
auth = Some(a)
break
}
}
if (let Some(auth) <- auth) {
// 1. 未登录 → 401
var loginItem: SimApiLoginItem = SimApiLoginItem("")
match (context.items.get("LoginInfo")) {
case Some(v) =>
if (let l: SimApiLoginItem <- v) {
loginItem = l
} else {
SimApiError.error(code: 401, message: "需要登录")
}
case None =>
SimApiError.error(code: 401, message: "需要登录")
}
// 2. 遍历执行 ISimApiAuthChecker(对齐 C# GetServices<ISimApiAuthChecker>():一次解析全部实现)
let token = match (context.items.get("LoginToken")) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
let checkers = context.services.getAll<ISimApiAuthChecker>()
for (checker in checkers) {
checker.run(loginItem, token)
}
// 3. 类型权限校验 → 403(对齐 C# Types.Intersect(loginInfo.Type).Any(),支持逗号分隔多类型)
if (!auth.`type`.isEmpty()) {
let requiredTypes = auth.`type`.split(",")
var matched = false
for (t in requiredTypes) {
if (loginItem.types.contains(t)) {
matched = true
break
}
}
if (!matched) {
SimApiError.error(code: 403, message: "无权访问")
}
}
}
}
/// 模型绑定失败时写入 ProblemDetails 响应
private func handleInvalidModelState(modelBindingContext: ActionBindingContext) {
let options = context.services.getOrThrow<IOptions<ApiBehaviorOptions>>()
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)
}
// 与 soulsoft writeAsJson 的 Serializable<T> 分支一致:serialize().toJson().toString()
// toJson 为 DataModel 接口扩展,需先上转);writeAsJson 内部会设置 contentType,此处补上
context.response.contentType = "application/json; charset=utf-8"
let dm: DataModel = details.serialize()
let jsonText = dm.toJson().toString()
SimApiResponseWriter.write(context, jsonText)
}
}
/// 结果派发 + 自动封装(对齐 C# SimApiResponseFilter
private func dispatchResult(actionResult: Any) {
// @OriginResponse:跳过统一封装,原样输出(对齐 C# OnResultExecuting 遇注解直接 return
var originResponse = false
for (item in actionDescriptor.endpointMetadata) {
if (item is OriginResponse) {
originResponse = true
break
}
}
if (originResponse) {
if (let s: String <- actionResult) {
// String 原样输出文本(对齐 C# string 返回直接写入)
context.response.contentType = "application/json; charset=utf-8"
SimApiResponseWriter.write(context, s)
} else {
ObjectResult<Any>(actionResult).invoke(context)
}
return
}
// 统一封装(IActionResult/SimApiBaseResponse/String/Unit/其他对象)
SimApiResultWriter.write(context, actionResult)
}
/// 根据 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
/// (通过 IBindRequestContext 接口而非 SimApiBaseController,避免 helpers↔controllers 循环依赖)
private func createControllerInstance(): Object {
let instance = ActivatorUtilities.createInstance(context.services, actionDescriptor.controllerType)
if (let controller: IBindRequestContext <- instance) {
controller.bindRequestContext(context)
}
return instance
}
}
+5 -4
View File
@@ -3,17 +3,17 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import soulsoft_web_http.*
import simapi.communications.*
import simcu::serialization.*
import simcu::simapi.communications.*
/**
* 响应封装:对写操作(Unit)返回统一成功响应,对已有 SimApiBaseResponse 透传。
* 在仓颉版中以中间件形式实现,对应 C# 的 SimApiResponseFilter。
*/
public class SimApiResponseFilter {
public init() {}
/**
* 包装响应委托:捕获下一级写入的响应内容。
@@ -25,7 +25,8 @@ public class SimApiResponseFilter {
context =>
next(context)
if (!context.response.hasStarted) {
context.response.writeAsJson(SimApiBaseResponse())
context.response.contentType = "application/json; charset=utf-8"
SimApiResponseWriter.write(context, JsonSerializer.Serialize(SimApiBaseResponse()))
}
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.helpers
import soulsoft_web_http.*
/**
* 响应写出工具:写出响应体并缓存文本,供请求日志中间件读取。
*
* 背景:soulsoft 的 HttpResponse.body 为只写流(read 抛 UnsupportedException),
* 无法像 C# 那样用 MemoryStream 替换 Body 捕获响应内容;
* 故在统一写出入口缓存文本,请求日志中间件直接从 context.items 读取。
*/
public class SimApiResponseWriter {
private init() {}
/**
* 响应体缓存键(写入 HttpContext.items)。
*/
public static let responseBodyCacheKey = "SimApi:ResponseBodyCache"
/**
* 写出响应文本并缓存(供请求日志中间件读取)。
* @param context HTTP 上下文。
* @param text 响应正文文本。
*/
public static func write(context: HttpContext, text: String): Unit {
context.items[responseBodyCacheKey] = text
context.response.write(text)
}
}
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.helpers
import soulsoft_web_http.*
import soulsoft_web_mvc.core.*
import simcu::serialization.*
import simcu::simapi.communications.*
/**
* 统一响应封装工具(对齐 C# SimApiResponseFilter 的包装分支)。
* 供 SimApiRequestDelegateFactory 与内置路由委托复用:
* - SimApiBaseResponse(含子类)→ 原样输出
* - String → SimApiResponse<String>data 为字符串)
* - Unitvoid)→ SimApiBaseResponse(){code:200, message:成功}
* - 其他对象(DTO/数组/动态结构)→ SimApiDataResponsedata 内嵌为对象)
*
* 输出使用 simapi_serialization 序列化后直接写响应体(不走 soulsoft formatter)。
*/
public class SimApiResultWriter {
private init() {}
public static func write(context: HttpContext, actionResult: Any): Unit {
if (let result: IActionResult <- actionResult) {
// 显式返回 IActionResult(如 ContentResult)→ 原样
result.invoke(context)
} else if (let result: SimApiBaseResponse <- actionResult) {
// 已是 SimApiBaseResponse(含子类)→ 原样输出
writeJson(context, result)
} else if (let result: String <- actionResult) {
// String → SimApiResponse<String>data 为字符串)
writeJson(context, SimApiResponse<String>(result))
} else if (let result: Unit <- actionResult) {
// void/无返回 → SimApiBaseResponse(){code:200, message:成功}
writeJson(context, SimApiBaseResponse())
} else {
// 其他对象(DTO/数组/动态结构)→ SimApiDataResponse
writeJson(context, SimApiDataResponse(actionResult))
}
}
/// simapi_serialization 序列化后直接写响应体(经 SimApiResponseWriter 缓存,供请求日志读取)
private static func writeJson(context: HttpContext, obj: Any): Unit {
context.response.contentType = "application/json; charset=utf-8"
SimApiResponseWriter.write(context, JsonSerializer.Serialize(obj))
}
}
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.helpers
import std.convert.*
import soulsoft_web_http.*
import simcu::simapi.exceptions.*
import simcu::simapi.interfaces.*
/**
* 服务端验签校验器(对齐 C# Attributes/SimApiSignAttribute.OnActionExecuting)。
*
* 仓颉无声明式 ActionFilter 机制,按项目惯例(同 requireLogin)由控制器在需要验签的方法开头调用:
* SimApiSignChecker.verify(context, provider, cache)
* 或标注 @SimApiSign 注解自动执行(SimApiRequestDelegateFactory)。
*
* 校验流程(与 C# 完全一致):
* 1. 提取 appIdQuery/Header
* 2. provider.getKey(appId) 获取密钥
* 3. 提取并解析 timestamp / nonce
* 4. QueryExpires 过期校验(ts > now+2 → 校准时间;ts+expires < now → 已过期)
* 5. DuplicateRequestProtectionnonce 去重(缓存 "SignQuery:{nonce}"
* 6. 拼接 SignFields + appId + timestamp + nonce + keyMD5 比对 sign
*/
public class SimApiSignChecker {
private init() {}
/**
* 校验当前请求签名。
* @param context 当前请求上下文。
* @param provider 签名提供器(含字段名/过期/去重配置与密钥获取)。
* @param cache 缓存(nonce 去重用;None 时跳过去重,保持兼容)。
*/
public static func verify(context: HttpContext, provider: SimApiSignProviderBase, cache: ?SimApiCache): Unit {
// 1. 提取 appId
var appId: ?String = None
if (let Some(name) <- provider.appIdName) {
if (!name.isEmpty()) {
appId = getParam(context, name)
if (appId == None || appId == Some("")) {
SimApiError.error(code: 400, message: "获取${name}失败")
}
}
}
// 2. 获取密钥
let key = provider.getKey(appId)
if (key == None || key == Some("")) {
SimApiError.error(code: 400, message: "获取签名KEY失败")
}
let secret = key.getOrThrow()
// 3. 提取 timestamp / nonce
let timestamp = getParam(context, provider.timestampName)
if (timestamp == None || timestamp == Some("")) {
SimApiError.error(code: 400, message: "${provider.timestampName}不能为空")
}
let nonce = getParam(context, provider.nonceName)
if (nonce == None || nonce == Some("")) {
SimApiError.error(code: 400, message: "${provider.nonceName}不能为空")
}
let tsStr = timestamp.getOrThrow()
let nonceStr = nonce.getOrThrow()
let ts = parseTimestamp(tsStr)
// 4. 过期校验
if (provider.queryExpires != 0) {
let now = Int64(SimApiUtil.timestampNow)
if (ts > now + 2) {
SimApiError.error(code: 400, message: "请校准本地时间")
}
if (ts + provider.queryExpires < now) {
SimApiError.error(code: 400, message: "请求已过期")
}
// 5. nonce 去重
if (provider.duplicateRequestProtection) {
if (let Some(cache) <- cache) {
let nonceKey = "SignQuery:${nonceStr}"
if (!cache.hasKey(nonceKey)) {
cache.set(nonceKey, tsStr, expireSeconds: provider.queryExpires + 2)
} else {
SimApiError.error(code: 400, message: "重复请求")
}
}
}
}
// 6. 拼接签名串并比对
var sb = StringBuilder()
for (field in provider.signFields) {
sb.append("${field}=")
if (let Some(v) <- getParam(context, field)) {
sb.append(v)
}
sb.append("&")
}
if (let Some(name) <- provider.appIdName) {
if (!name.isEmpty()) {
sb.append("${name}=${appId.getOrThrow()}&")
}
}
sb.append("${provider.timestampName}=${ts}&${provider.nonceName}=${nonceStr}&${secret}")
let expect = SimApiUtil.md5(sb.toString())
let sign = getParam(context, provider.signName)
if (sign == None || sign != Some(expect)) {
SimApiError.error(code: 400, message: "签名错误")
}
}
/// 从 Query 或 Header 取参数(Query 优先,对齐 C# FirstOrDefault 语义)
private static func getParam(context: HttpContext, name: String): ?String {
let q = context.request.query.get(name)
if (q != None && q != Some("")) {
return q
}
context.request.headers.get(name)
}
/// 解析秒级时间戳
private static func parseTimestamp(s: String): Int64 {
try {
Int64.parse(s)
} catch (ex: Exception) {
SimApiError.error(code: 400, message: "时间戳格式错误")
}
Int64.parse(s)
}
}
+552
View File
@@ -0,0 +1,552 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* Helpers/SimApiStorageS3/MinIO 兼容存储助手。
*
* 仓颉生态暂无 Minio SDK,此处自实现 AWS Signature V4HMAC-SHA256 基于 stdx SHA256):
* - 预签名 URLPUT 上传 / GET 下载)
* - 直接 PUT 上传(对象 + 建桶)
* - HEAD 检测桶是否存在
* 与 C# SimApiStorageMinio SDK)的公开方法语义对齐:
* GetUploadUrl / GetDownloadUrl / UploadFile / FullUrl / GetUrl / GetPath
*/
package simcu::simapi.helpers
import std.collection.*
import std.io.*
import std.time.*
import stdx.crypto.digest.*
import stdx.encoding.base64.*
import stdx.encoding.hex.*
import stdx.net.http.*
import stdx.net.tls.*
import stdx.net.tls.common.*
import soulsoft_web_http.*
import simcu::simapi.configurations.*
/**
* 上传预签名 URL 响应(对齐 C# record GetUploadUrlResponse(UploadUrl, DownloadUrl, Path))。
*/
public class GetUploadUrlResponse {
public var uploadUrl: String = ""
public var downloadUrl: String = ""
public var path: String = ""
public init() {}
public init(uploadUrl: String, downloadUrl: String, path: String) {
this.uploadUrl = uploadUrl
this.downloadUrl = downloadUrl
this.path = path
}
}
/**
* S3/MinIO 存储助手(对齐 C# Helpers/SimApiStorage)。
*
* 说明:
* - 注册为 Scoped 以注入 IHttpContextAccessorsoulsoft DI 禁止 singleton 消费 scoped 服务);
* 桶的检测/创建由静态守卫保证整个进程只执行一次(对齐 C# 构造函数中 BucketExists+MakeBucket)。
* - fullUrl/getUrl 的 "~/" 分支依赖当前请求上下文(对齐 C# IHttpContextAccessor)。
*/
public class SimApiStorage {
private static var _bucketEnsured: Bool = false
private let _endpoint: String
private let _serveUrl: String
private let _bucket: String
private let _accessKey: String
private let _secretKey: String
private let _useSsl: Bool
private let _host: String
private let _region: String = "us-east-1"
private let _httpContextAccessor: IHttpContextAccessor
public init(options: SimApiOptions, httpContextAccessor: IHttpContextAccessor) {
let storage = options.simApiStorageOptions
if (storage.endpoint.isEmpty() || storage.serveUrl.isEmpty() || storage.bucket.isEmpty()) {
throw Exception("SimApiStorage: Endpoint/ServeUrl/Bucket 不能为空")
}
var useSsl = false
var host = ""
if (storage.endpoint.startsWith("http://")) {
host = storage.endpoint["http://".size..]
} else if (storage.endpoint.startsWith("https://")) {
useSsl = true
host = storage.endpoint["https://".size..]
} else {
throw Exception("SimApiStorage: Error Endpoint")
}
if (storage.serveUrl.endsWith("/")) {
throw Exception("SimApiStorage: ServeUrl must not end with /")
}
_endpoint = storage.endpoint
_serveUrl = storage.serveUrl
_bucket = storage.bucket
_accessKey = storage.accessKey
_secretKey = storage.secretKey
_useSsl = useSsl
_host = host
_httpContextAccessor = httpContextAccessor
// 桶不存在则创建(对齐 C# BucketExists + MakeBucket;静态守卫保证只执行一次)
ensureBucketOnce()
}
/**
* 获取上传预签名 URL(对齐 C# GetUploadUrl,默认 7200 秒)。
*/
public func getUploadUrl(path: String, expire!: Int64 = 7200): GetUploadUrlResponse {
checkPath(path)
let obj = trimLeadingSlash(path)
let uploadUrl = presign("PUT", obj, expire)
GetUploadUrlResponse(uploadUrl, "${_serveUrl}${path}", path)
}
/**
* 获取下载预签名 URL(对齐 C# GetDownloadUrl,默认 600 秒)。
*/
public func getDownloadUrl(path: String, expire!: Int64 = 600): String {
checkPath(path)
let obj = trimLeadingSlash(path)
presign("GET", obj, expire)
}
/**
* 直接上传文件(对齐 C# UploadFiledata 为文件字节)。
*/
public func uploadFile(path: String, data: Array<Byte>, contentType!: String = "image/png"): Unit {
checkPath(path)
let obj = trimLeadingSlash(path)
putObject(obj, data, contentType)
}
/**
* 批量删除对象(对齐 C# Minio RemoveObjectsAsyncS3 原生 DeleteObjects 接口:
* POST /{bucket}?delete,一次请求删除多个对象,无需逐个删除)。
* @param paths 对象路径数组(每个须以 / 开头)。
*/
public func deleteFiles(paths: Array<String>): Unit {
if (paths.isEmpty()) {
return
}
for (p in paths) {
checkPath(p)
}
var sb = StringBuilder()
sb.append("<Delete xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">")
for (p in paths) {
sb.append("<Object><Key>${xmlEscape(trimLeadingSlash(p))}</Key></Object>")
}
sb.append("<Quiet>true</Quiet></Delete>")
deleteObjects(sb.toString())
}
/**
* 使用 path 获取完整的访问 URL(对齐 C# FullUrl)。
*/
public func fullUrl(path: ?String): ?String {
if (let Some(p) <- path) {
if (p.isEmpty() || p.startsWith("http://") || p.startsWith("https://")) {
return Some(p)
}
if (!(p.startsWith("/") || p.startsWith("~/"))) {
return Some(p)
}
if (p.startsWith("~/")) {
return Some("${requestBaseUrl()}${p[1..]}")
}
return Some("${_serveUrl}${p}")
}
None
}
/**
* 获取一个 Path 的访问 URL(对齐 C# GetUrl)。
*/
public func getUrl(path: ?String): ?String {
if (let Some(p) <- path) {
if (p.isEmpty()) {
return Some(p)
}
if (p.startsWith("~/")) {
return Some("${requestBaseUrl()}${p[1..]}")
}
if (p.startsWith("/")) {
return Some("${_serveUrl}${p}")
}
return Some(p)
}
None
}
/**
* 从 URL 中获取相对路径(对齐 C# GetPath;去掉 Endpoint/Bucket 或 ServeUrl 前缀)。
*/
public func getPath(url: ?String): ?String {
if (let Some(u) <- url) {
var r = u
let prefix = "${_endpoint}/${_bucket}"
if (r.startsWith(prefix)) {
r = r[prefix.size..]
}
if (r.startsWith(_serveUrl)) {
r = r[_serveUrl.size..]
}
return Some(r)
}
None
}
// ===== 私有:SigV4 签名与 S3 请求 =====
/// 预签名 URL(对齐 Minio PresignedPutObject / PresignedGetObject
private func presign(method: String, object: String, expireSeconds: Int64): String {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let canonicalUri = "/${_bucket}/${uriEncode(object, false)}"
let credential = "${_accessKey}/${dateStamp}/${_region}/s3/aws4_request"
// 五个 X-Amz-* 参数按字典序排列(A<C<D<E<S
let canonicalQuery = "X-Amz-Algorithm=${uriEncode("AWS4-HMAC-SHA256", true)}" +
"&X-Amz-Credential=${uriEncode(credential, true)}" +
"&X-Amz-Date=${amzDate}" +
"&X-Amz-Expires=${expireSeconds}" +
"&X-Amz-SignedHeaders=host"
let canonicalHeaders = "host:${_host}\n"
let signedHeaders = "host"
let canonicalRequest = "${method}\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let signature = toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray()))
"${_endpoint}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}"
}
/// 直接 PUT 上传对象(Authorization 头签名,对齐 Minio PutObject
private func putObject(object: String, data: Array<Byte>, contentType: String): Unit {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(data))
let canonicalUri = "/${_bucket}/${uriEncode(object, false)}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n"
let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
let canonicalRequest = "PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestBuilder().
put().
url("${_endpoint}${canonicalUri}").
header("x-amz-content-sha256", payloadHash).
header("x-amz-date", amzDate).
header("Authorization", authorization).
header("Content-Type", contentType).
body(data).
build()
let response = client.send(request)
try {
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "SimApiStorage 上传失败: HTTP ${response.status}")
} finally {
response.close()
}
} finally {
client.close()
}
}
/// HEAD 检测桶是否存在(对齐 Minio BucketExistsAsync
private func bucketExists(): Bool {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(Array<Byte>(0, repeat: 0)))
let canonicalUri = "/${_bucket}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n"
let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
let canonicalRequest = "HEAD\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestBuilder().
head().
url("${_endpoint}${canonicalUri}").
header("x-amz-content-sha256", payloadHash).
header("x-amz-date", amzDate).
header("Authorization", authorization).
build()
let response = client.send(request)
try {
response.status == 200
} finally {
response.close()
}
} finally {
client.close()
}
}
/// 创建桶(对齐 Minio MakeBucketAsync
private func makeBucket(): Unit {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(Array<Byte>(0, repeat: 0)))
let canonicalUri = "/${_bucket}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n"
let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
let canonicalRequest = "PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestBuilder().
put().
url("${_endpoint}${canonicalUri}").
header("x-amz-content-sha256", payloadHash).
header("x-amz-date", amzDate).
header("Authorization", authorization).
body(Array<UInt8>(0, repeat: 0u8)).
build()
let response = client.send(request)
try {
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "SimApiStorage 创建桶失败: HTTP ${response.status}")
} finally {
response.close()
}
} finally {
client.close()
}
}
private func ensureBucketOnce(): Unit {
if (!SimApiStorage._bucketEnsured) {
if (!bucketExists()) {
makeBucket()
}
SimApiStorage._bucketEnsured = true
}
}
/// 批量删除请求(S3 DeleteObjectsPOST /{bucket}?delete + XML
private func deleteObjects(xml: String): Unit {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(xml.toArray()))
let canonicalUri = "/${_bucket}"
let canonicalQuery = "delete="
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n"
let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
let canonicalRequest = "POST\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let contentMd5 = md5Base64(xml.toArray())
let request = HttpRequestBuilder().
post().
url("${_endpoint}${canonicalUri}?${canonicalQuery}").
header("x-amz-content-sha256", payloadHash).
header("x-amz-date", amzDate).
header("Authorization", authorization).
header("Content-Type", "application/xml").
// MinIO 的 DeleteObjects 强制要求 Content-Md5(缺失返回 MissingContentMD5
header("Content-Md5", contentMd5).
body(xml).
build()
let response = client.send(request)
try {
SimApiError.errorWhenFalse(isSuccess(response.status), code: Int64(response.status),
message: "SimApiStorage 批量删除失败: HTTP ${response.status}")
} finally {
response.close()
}
} finally {
client.close()
}
}
private func buildAuthorization(dateStamp: String, signedHeaders: String, signature: String): String {
"AWS4-HMAC-SHA256 Credential=${_accessKey}/${dateStamp}/${_region}/s3/aws4_request, " +
"SignedHeaders=${signedHeaders}, Signature=${signature}"
}
private func buildSigningKey(dateStamp: String): Array<Byte> {
let kDate = hmacSha256("AWS4${_secretKey}".toArray(), dateStamp.toArray())
let kRegion = hmacSha256(kDate, _region.toArray())
let kService = hmacSha256(kRegion, "s3".toArray())
hmacSha256(kService, "aws4_request".toArray())
}
private func createClient(): Client {
let builder = ClientBuilder().
noProxy().
readTimeout(Duration.second * 60)
if (_useSsl) {
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
let host = extractHost(_endpoint)
if (!host.isEmpty()) {
tls.serverName = Some(host)
}
return builder.tlsConfig(tls).build()
}
builder.build()
}
/// 2xx 视为成功
private static func isSuccess(status: UInt16): Bool {
status >= 200 && status < 300
}
private func checkPath(path: String): Unit {
if (!path.startsWith("/")) {
throw Exception("path must start with /")
}
}
private func trimLeadingSlash(path: String): String {
if (path.startsWith("/")) {
path[1..]
} else {
path
}
}
/// 当前请求基础地址(scheme://host);无请求上下文时返回空串
private func requestBaseUrl(): String {
if (let Some(ctx) <- _httpContextAccessor.context) {
return "${ctx.request.scheme}://${ctx.request.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 }
rest[0..end]
case None => ""
}
}
private static func formatAmzDate(dt: DateTime): String {
"${dt.year}${pad2(dt.monthValue)}${pad2(dt.dayOfMonth)}T${pad2(dt.hour)}${pad2(dt.minute)}${pad2(dt.second)}Z"
}
private static func pad2(v: Int64): String {
if (v < 10) {
"0${v}"
} else {
"${v}"
}
}
/// XML 特殊字符转义(对象 Key 中可能含 & < > " '
private static func xmlEscape(s: String): String {
var sb = StringBuilder()
for (c in s.runes()) {
match (c) {
case '&' => sb.append("&amp;")
case '<' => sb.append("&lt;")
case '>' => sb.append("&gt;")
case '"' => sb.append("&quot;")
case '\'' => sb.append("&apos;")
case _ => sb.append(c)
}
}
sb.toString()
}
/// RFC 3986 百分号编码(encodeSlash=false 时保留 '/'
private static func uriEncode(s: String, encodeSlash: Bool): String {
let bytes = s.toArray()
let hex = "0123456789ABCDEF"
var sb = StringBuilder()
for (b in bytes) {
let u = toU8(b)
let unreserved = (u >= 0x41u8 && u <= 0x5Au8) || (u >= 0x61u8 && u <= 0x7Au8) ||
(u >= 0x30u8 && u <= 0x39u8) || u == 0x2Du8 || u == 0x5Fu8 || u == 0x2Eu8 || u == 0x7Eu8
if (unreserved || (u == 0x2Fu8 && !encodeSlash)) {
sb.append(Rune(UInt32(u)))
} else {
sb.append("%")
sb.append(Rune(UInt32(hex[Int64((u >> 4u8) & 0x0Fu8)])))
sb.append(Rune(UInt32(hex[Int64(u & 0x0Fu8)])))
}
}
sb.toString()
}
private static func toU8(b: Byte): UInt8 {
if (b < 0) {
UInt8(Int64(b) + 256)
} else {
UInt8(Int64(b))
}
}
/// HMAC-SHA256(自实现,基于 stdx SHA256stdx 静态库无现成 HMAC 封装)
private static func hmacSha256(key: Array<Byte>, data: Array<Byte>): Array<Byte> {
var k = key
if (k.size > 64) {
k = sha256Bytes(k)
}
var keyPadded = Array<Byte>(64, repeat: 0)
for (i in 0..k.size) {
keyPadded[i] = k[i]
}
var inner = ArrayList<Byte>()
for (i in 0..64) {
inner.add(bxor(keyPadded[i], 0x36u8))
}
for (b in data) {
inner.add(b)
}
let innerHash = sha256Bytes(inner.toArray())
var outer = ArrayList<Byte>()
for (i in 0..64) {
outer.add(bxor(keyPadded[i], 0x5Cu8))
}
for (b in innerHash) {
outer.add(b)
}
sha256Bytes(outer.toArray())
}
@OverflowWrapping
private static func bxor(a: Byte, mask: UInt8): Byte {
let ua = toU8(a)
let r: Byte = ua ^ mask
r
}
private static func sha256Bytes(data: Array<Byte>): Array<Byte> {
let sha = SHA256()
sha.write(data)
sha.finish()
}
/// MD5 → Base64Content-Md5 头,S3 DeleteObjects 必需)
private static func md5Base64(data: Array<Byte>): String {
let md = MD5()
md.write(data)
toBase64String(md.finish())
}
}
+105 -10
View File
@@ -3,7 +3,7 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
package simcu::simapi.helpers
import std.collection.*
import std.time.*
@@ -12,8 +12,9 @@ import stdx.crypto.digest.*
import stdx.encoding.hex.*
import stdx.encoding.base64.*
import std.regex.*
import simapi.communications.*
import simapi.macros.*
import simcu::serialization.*
import simcu::simapi.communications.*
import simcu::simapi.macros.*
/**
* 工具类:对应 C# 的 SimApi.Helpers.SimApiUtil。
@@ -59,10 +60,10 @@ public class SimApiUtil {
* @param source 源字符串。
* @return 32 位十六进制小写。
*/
public static func md5(source: String): String {
public static func md5(source: String, mode!: String = "x2"): String {
let md = MD5()
md.write(source.toArray())
toHexString(md.finish())
formatHex(md.finish(), mode)
}
/**
@@ -70,10 +71,10 @@ public class SimApiUtil {
* @param source 源字符串。
* @return 40 位十六进制小写。
*/
public static func sha1(source: String): String {
public static func sha1(source: String, mode!: String = "x2"): String {
let sha = SHA1()
sha.write(source.toArray())
toHexString(sha.finish())
formatHex(sha.finish(), mode)
}
/**
@@ -87,6 +88,28 @@ public class SimApiUtil {
toHexString(sha.finish())
}
/// 按 .NET Md5/Sha1 的 mode 格式化:x2/x3/x4 → 每个字节 2/3/4 位十六进制
private static func formatHex(bytes: Array<Byte>, mode: String): String {
if (mode == "x2") {
return toHexString(bytes)
}
let two = toHexString(bytes)
var sb = StringBuilder()
for (i in 0..bytes.size) {
let pair = two[i * 2..i * 2 + 2]
if (mode == "x3") {
sb.append("0")
sb.append(pair)
} else if (mode == "x4") {
sb.append("00")
sb.append(pair)
} else {
sb.append(pair)
}
}
sb.toString()
}
/**
* 字符串 Base64 编码。
*/
@@ -110,7 +133,8 @@ public class SimApiUtil {
}
/**
* 判断是否是 Email 地址(简化校验)
* 判断是否是 Email 地址。
* 说明:.NET 使用 System.Net.Mail.MailAddress 校验,仓颉无等价 API,此处用正则近似。
*/
public static func checkEmail(email: String): Bool {
Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$").matches(email)
@@ -148,9 +172,80 @@ public class SimApiUtil {
}
/**
* 将对象序列化为 JSON 字符串(委托给 SimApiJson.json 统一实现,对齐 C# SimApiUtil.Json)。
* 将对象序列化为 JSON 字符串(对齐 C# SimApiUtil.Json)。
* @param obj 任意对象(None 输出 null)。
*/
public static func json(obj: ?Any): String {
SimApiJson.json(obj)
if (let Some(obj) <- obj) {
return JsonSerializer.Serialize(obj)
}
"null"
}
/**
* JSON 字符串转义(对齐 C# 内部转义逻辑)。
* @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()
}
/**
* 从 JSON 字符串反序列化为 T(对齐 C# SimApiUtil.FromJson<T>)。
* @param T 目标类型(任意类,无需接口/宏约束)。
* @param jsonString JSON 字符串。
* @return 反序列化结果。
*/
public static func fromJson<T>(jsonString: String): T {
JsonSerializer.Deserialize<T>(jsonString)
}
/**
* 对象 Base64 编码(对象 → JSON → Base64,对齐 C# Base64Encode(object))。
* @param obj 任意对象(DTO/基础类型/HashMap 等)。
* @return Base64 字符串。
*/
public static func base64Encode(obj: Any): String {
let json = json(Some(obj))
base64Encode(json)
}
/**
* Base64 → JSON → T 反序列化(对齐 C# Base64Decode<T>)。
* @param T 目标类型(任意类,无需接口/宏约束)。
* @param base64Str Base64 字符串。
* @return 反序列化结果。
*/
public static func base64DecodeTo<T>(base64Str: String): T {
fromJson<T>(base64Decode(base64Str))
}
/**
* 分页(对齐 C# Paginate 扩展;仓颉无 IQueryable,改为对 Array<T> 切片)。
*/
public static func paginate<T>(list: Array<T>, page: Int64, count: Int64): Array<T> {
let p = if (page < 1) { 1 } else { page }
let c = if (count <= 0) { 10 } else { count }
let skip = (p - 1) * c
let total = list.size
if (skip >= total) {
return Array<T>()
}
let end = if (skip + c > total) { total } else { skip + c }
list[skip..end]
}
// 说明:C# 的 XmlDeserialize<T> 依赖 System.Xml.Serialization,仓颉生态无 XML 序列化库,未移植。
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AES body 密钥提供器基类(对齐 C# ModelBinders/AesBodyProviderBase)。
*
* 说明:本类含配置字段(appIdName),故用 open class 而非 interface
* (Cangjie 接口不能声明字段),与 .NET 抽象类对应。应用继承本类并实现 getKey。
*/
package simcu::simapi.interfaces
/**
* AES body 密钥提供器基类:应用继承并实现 getKey(appId),返回 appId 对应的 AES 密钥。
*/
public open class AesBodyProviderBase {
/// appId 字段名(None 表示不带 appId
public var appIdName: ?String = Some("appId")
/**
* 根据 appId 获取密钥。
* @param appId 应用 ID(未配置 appIdName 时为 None)。
* @return 密钥;返回 None 表示获取失败。
*/
public open func getKey(appId: ?String): ?String {
None
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* Interfaces/IBindRequestContext:请求上下文绑定接口。
*
* 说明:SimApiBaseController 实现本接口,由 SimApiRequestDelegateFactorysimapi.helpers
* 在创建控制器后注入当前 HttpContext。放在 interfaces 包是为了避免
* simapi.helpers → simapi.controllers 的循环依赖(controllers 依赖 helpers 的 SimApiError/SimApiAuth/SimApiUtil)。
*/
package simcu::simapi.interfaces
import soulsoft_web_http.*
/**
* 请求上下文绑定接口:控制器实现后,框架在派发请求时把当前 HttpContext 注入。
*/
public interface IBindRequestContext {
/**
* 绑定当前请求上下文。
* @param context 当前请求的 HttpContext。
*/
func bindRequestContext(context: HttpContext): Unit
}
+2 -2
View File
@@ -3,9 +3,9 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.interfaces
package simcu::simapi.interfaces
import simapi.communications.*
import simcu::simapi.communications.*
/**
* 认证后处理 Hook:实现后每次认证成功都会调用。
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* 签名提供器基类(对齐 C# ModelBinders/SimApiSignProviderBase)。
*
* 说明:本类含配置字段(appIdName/queryExpires 等),故用 open class 而非 interface
* (Cangjie 接口不能声明字段),与 .NET 抽象类对应。应用继承本类并实现 getKey。
*/
package simcu::simapi.interfaces
/**
* 签名提供器基类:应用继承并实现 getKey(appId),返回 appId 对应的密钥。
*/
public open class SimApiSignProviderBase {
/// appId 字段名(None 表示签名中不包含 appId)
public var appIdName: ?String = Some("appId")
/// 时间戳字段名
public var timestampName: String = "timestamp"
/// 随机串字段名
public var nonceName: String = "nonce"
/// 签名字段名
public var signName: String = "sign"
/// 请求过期秒数(0 表示不校验 timestamp
public var queryExpires: Int64 = 5
/// 是否开启 nonce 去重(需配置缓存)
public var duplicateRequestProtection: Bool = true
/// 参与签名的额外字段(与 appId/timestamp/nonce 一起拼入签名字符串)
public var signFields: Array<String> = []
/**
* 根据 appId 获取密钥。
* @param appId 应用 ID(未配置 appIdName 时为 None)。
* @return 密钥;返回 None 表示获取失败。
*/
public open func getKey(appId: ?String): ?String {
None
}
}
+8 -7
View File
@@ -3,7 +3,7 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.logger
package simcu::simapi.logger
import std.collection.concurrent.*
import std.env.*
@@ -48,7 +48,7 @@ public class SimApiLogger <: ILogger {
}
public func isEnabled(logLevel: LogLevel): Bool {
logLevel != LogLevel.Off
true
}
/**
@@ -96,7 +96,7 @@ public class SimApiLogger <: ILogger {
let hour = pad2(dt.hour)
let minute = pad2(dt.minute)
let second = pad2(dt.second)
let millis = pad3(dt.nanosecond / 1000000)
let millis = pad4(dt.nanosecond / 100000)
"${year}-${month}-${day} ${hour}:${minute}:${second}:${millis}"
}
@@ -107,11 +107,14 @@ public class SimApiLogger <: ILogger {
"${v}"
}
private static func pad3(v: Int64): String {
private static func pad4(v: Int64): String {
if (v < 10) {
return "00${v}"
return "000${v}"
}
if (v < 100) {
return "00${v}"
}
if (v < 1000) {
return "0${v}"
}
"${v}"
@@ -124,8 +127,6 @@ public class SimApiLogger <: ILogger {
public class SimApiLoggerProvider <: ILoggerProvider {
private let _loggers = ConcurrentHashMap<String, SimApiLogger>()
public init() {}
public func createLogger(categoryName: String): ILogger {
if (let Some(logger) <- _loggers.get(categoryName)) {
return logger
+1 -1
View File
@@ -1,4 +1,4 @@
macro package simapi.macros
macro package simcu::simapi.macros
import std.ast.*
import std.fs.*
+3 -3
View File
@@ -3,11 +3,11 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.middlewares
package simcu::simapi.middlewares
import soulsoft_web_http.*
import simapi.communications.*
import simapi.helpers.*
import simcu::simapi.communications.*
import simcu::simapi.helpers.*
/**
* 认证信息获取中间件:从 Header Token 或 Query token 解析登录信息并注入上下文。
+8 -6
View File
@@ -3,13 +3,15 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.middlewares
package simcu::simapi.middlewares
import soulsoft_web_http.*
import soulsoft_extensions_logging.*
import simapi.communications.*
import simapi.exceptions.*
import simapi.configurations.*
import simcu::serialization.*
import simcu::simapi.communications.*
import simcu::simapi.exceptions.*
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
/**
* 异常处理中间件:全异常捕获,统一输出 HTTP 200 + JSON 响应。
@@ -46,7 +48,7 @@ public class SimApiExceptionMiddleware <: IMiddleware {
if (!context.response.hasStarted) {
context.response.statusCode = 200
context.response.contentType = "application/json; charset=utf-8"
context.response.write(responseJson(response))
SimApiResponseWriter.write(context, responseJson(response))
}
}
}
@@ -69,6 +71,6 @@ public class SimApiExceptionMiddleware <: IMiddleware {
}
private func responseJson(response: SimApiBaseResponse): String {
response.toJsonString()
JsonSerializer.Serialize(response)
}
}
+135 -23
View File
@@ -3,19 +3,31 @@
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.middlewares
package simcu::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.*
import simcu::serialization.*
import simcu::simapi.communications.*
import simcu::simapi.configurations.*
import simcu::simapi.helpers.*
/**
* 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码与耗时
* 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码、响应体、耗时与异常
* 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。
*
* 对齐说明:
* - 请求体按 JSON 字段级截断(对齐 C#:仅对超长字符串字段截断,保留结构)
* - 捕获下游异常并记录,随后重抛(对齐 C# ExceptionDispatchInfo + edi.Throw
* - 响应体:soulsoft HttpResponse.body 只读且不可读回(read 抛 UnsupportedException),
* 无法像 C# 那样用 MemoryStream 替换捕获;改为在各统一写出入口
* SimApiResponseWriter)缓存响应文本,此处直接读取。
* 响应行格式:*( Response [status] ) => [ N bytes ],随后换行输出响应体结构;
* ShowFullResponse=false 时截断到 200 字符(对齐 C#,长度仍显示完整字节数)
* - ShowFullUrl=false 时仅显示路径+查询串;ShowRunTime=true 时请求行显示 [POST] (xxxms)
*/
public class SimApiRequestLogMiddleware <: IMiddleware {
private let _options: SimApiOptions
@@ -31,13 +43,11 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
*/
public func invoke(context: HttpContext, next: RequestDelegate): Unit {
let start = MonoTime.now()
let fullUrl = context.request.getDisplayUrl()
let options = _options.simApiRequestLogOptions
var sb = StringBuilder()
sb.append("[${context.request.method}] ${fullUrl}\n")
// 请求头
if (_options.simApiRequestLogOptions.showFullHeader) {
if (options.showFullHeader) {
sb.append("*( RequestHeaders [Full] ) =>\n")
sb.append(serializeHeaders(context))
} else {
@@ -51,15 +61,84 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
sb.append("*( RequestBody ) =>\n")
sb.append(readRequestBody(context))
// 调用下一级
next(context)
// 调用下一级,捕获异常以便记录并重抛(对齐 C# ExceptionDispatchInfo
var exception: ?Exception = None
try {
next(context)
} catch (ex: Exception) {
exception = Some(ex)
}
// 响应信息
// 请求行(耗时需在 next 之后计算,故最后拼装)
let elapsed = MonoTime.now() - start
let elapsedMs = elapsed / Duration.millisecond
sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms\n")
let url = if (options.showFullUrl) {
buildDisplayUrl(context)
} else {
context.request.path.toString() + context.request.queryString.toString()
}
var sbHead = StringBuilder()
sbHead.append("[${context.request.method}]")
if (options.showRunTime) {
sbHead.append(" (${elapsedMs}ms)")
}
sbHead.append(" ${url}\n")
_logger.info(sb.toString())
// 响应信息:状态码 + 响应体长度(bytes),响应体结构换行显示
let responseBody = readResponseBody(context)
let bodyLen = if (responseBody.isEmpty()) {
context.response.contentLength ?? 0
} else {
responseBody.size
}
sb.append("*( Response [${context.response.statusCode}] ) => [ ${bodyLen} bytes ]\n")
if (!responseBody.isEmpty()) {
let display = if (_options.simApiRequestLogOptions.showFullResponse) {
responseBody
} else if (responseBody.size > 200) {
// 对齐 C#ShowFullResponse=false 时截断到 200 字符(长度仍显示完整字节数)
responseBody[0..200] + "...(${responseBody.size})"
} else {
responseBody
}
// 去掉响应体末尾换行:结构体最后不多加换行(logger 收尾会补一个换行)
var text = display
while (text.endsWith("\n")) {
text = text[0..text.size - 1]
}
sb.append(text)
}
if (let Some(ex) <- exception) {
sb.append("\nException: ${ex.toString()}\n")
}
_logger.info(sbHead.toString() + sb.toString())
// 重抛原异常(对齐 C# edi?.Throw()),由外层 ExceptionMiddleware 处理
if (let Some(ex) <- exception) {
throw ex
}
}
/// 构造完整请求 URL:stdx 服务端请求 URL 只有路径(无 scheme/host),需手动拼接。
/// 对齐 C# 的 {Scheme}://{Host}{Path}{QueryString}Host 优先取 Host 请求头。
private func buildDisplayUrl(context: HttpContext): String {
var sb = StringBuilder()
let scheme = context.request.scheme
sb.append(if (scheme.isEmpty()) { "http" } else { scheme })
sb.append("://")
let hostHeader = context.request.headers.get("Host") ?? ""
if (!hostHeader.isEmpty()) {
sb.append(hostHeader)
} else {
let host = context.request.host.toString()
if (!host.isEmpty()) {
sb.append(host)
}
}
sb.append(context.request.path.toString())
sb.append(context.request.queryString.toString())
sb.toString()
}
private func serializeHeaders(context: HttpContext): String {
@@ -68,7 +147,7 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
var first = true
for ((name, values) in context.request.headers) {
if (!first) { sb.append(",") }
sb.append("\"${SimApiJson.escapeJson(name)}\":\"${SimApiJson.escapeJson(joinValues(values))}\"")
sb.append("\"${SimApiUtil.escapeJson(name)}\":\"${SimApiUtil.escapeJson(joinValues(values))}\"")
first = false
}
sb.append("}\n")
@@ -97,27 +176,60 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
read = context.request.body.read(buffer)
}
let bodyText = sb.toString()
// 重置流位置,供后续业务读取
// 重置流位置,供后续业务读取;同时缓存 body(流可能不可重读)
if (let seekable: Seekable <- context.request.body) {
seekable.seek(SeekPosition.Begin(0))
}
context.items["SimApi:BodyCache"] = bodyText
return truncateBody(bodyText)
} catch (_: Exception) {
return "(读取请求体失败)\n"
}
}
/// 响应体读取:从 SimApiResponseWriter 缓存取完整响应体;未捕获到时返回空串
/// (长度与截断由调用方处理)
private func readResponseBody(context: HttpContext): String {
match (context.items.get(SimApiResponseWriter.responseBodyCacheKey)) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
}
/// 请求体截断:JSON 字段级截断(对齐 C#:仅对超长字符串字段截断),非 JSON 则整串截断
private func truncateBody(body: String): String {
if (_options.simApiRequestLogOptions.requestStringLogLength <= 0 ||
body.size <= _options.simApiRequestLogOptions.requestStringLogLength) {
let maxLen = _options.simApiRequestLogOptions.requestStringLogLength
if (maxLen <= 0) {
return body + "\n"
}
// 简单按长度截断(不做 JSON 字段级截断,保持实现简洁)
let chars = body.toArray()
var sb = StringBuilder()
for (i in 0.._options.simApiRequestLogOptions.requestStringLogLength) {
sb.append(chars[i])
try {
// 用 simapi_serialization 解析为动态结构,仅截断超长字符串字段(保持结构)
let map = JsonSerializer.Deserialize<HashMap<String, Any>>(body)
var sb = StringBuilder()
sb.append("{")
var first = true
for ((k, v) in map) {
if (!first) { sb.append(",") }
sb.append("\"${SimApiUtil.escapeJson(k)}\":")
if (let s: String <- v) {
if (s.size > maxLen) {
sb.append("\"${SimApiUtil.escapeJson(s[0..maxLen])}...(${s.size})\"")
} else {
sb.append("\"${SimApiUtil.escapeJson(s)}\"")
}
} else {
sb.append(SimApiUtil.json(Some(v)))
}
first = false
}
sb.append("}")
return sb.toString() + "\n"
} catch (_: Exception) {
}
return "${sb.toString()}...(${body.size})\n"
// 非 JSON 或解析失败:整串按长度截断
if (body.size <= maxLen) {
return body + "\n"
}
return body[0..maxLen] + "...(${body.size})\n"
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simcu::simapi.models
import std.collection.*
import std.reflect.*
import std.time.*
import simcu::simapi.helpers.*
/**
* 实体基类(对齐 C# Models/SimApiBaseModel)。
* 提供:
* - Id(默认 Guid)、CreatedAt / UpdatedAt(默认当前时间)
* - MapData:反射式字段映射(源 → 目标,同名 + 同类型;可忽略字段/白名单)
* - UpdateTime:更新 UpdatedAt
*/
public open class SimApiBaseModel {
public var _id: String = SimApiUtil.newGuid()
public var _updatedAt: DateTime = DateTime.now()
public var _createdAt: DateTime = DateTime.now()
/// MapData 默认忽略的字段(Id / CreatedAt / UpdatedAt
protected var _mapperIgnoreField: Array<String> = ["_id", "_createdAt", "_updatedAt"]
/// UpdateTime 更新的字段名
protected var _updatedTimeField: String = "_updatedAt"
/**
* 反射映射:把 source 的同名同类型非忽略字段赋值到 this(对齐 C# MapData(source, mapAll))。
* @param source 源对象。
* @param mapAll 为 true 时连忽略字段(Id/CreatedAt/UpdatedAt)也映射。
*/
public func mapData(source: Any, mapAll!: Bool = false): Unit {
let sourceProps = collectProps(TypeInfo.of(source))
let targetProps = collectProps(TypeInfo.of(this))
for (sp in sourceProps) {
if (mapAll || !_mapperIgnoreField.contains(sp.name)) {
copyProp(targetProps, sp, source, this)
}
}
updateTime()
}
/**
* 反射映射:仅映射白名单字段(对齐 C# MapData(source, mapFields))。
* @param source 源对象。
* @param mapFields 白名单字段名。
*/
public func mapData(source: Any, mapFields: Array<String>): Unit {
let sourceProps = collectProps(TypeInfo.of(source))
let targetProps = collectProps(TypeInfo.of(this))
for (sp in sourceProps) {
if (mapFields.contains(sp.name)) {
copyProp(targetProps, sp, source, this)
}
}
updateTime()
}
/**
* 更新 UpdatedAt 为当前时间(对齐 C# UpdateTime)。
*/
public func updateTime(): Unit {
let targetProps = collectProps(TypeInfo.of(this))
for (tp in targetProps) {
if (tp.name == _updatedTimeField) {
tp.setValue(this, DateTime.now())
return
}
}
}
// ===== 内部实现 =====
/// 把源属性 sp 的值复制到目标对象(要求目标存在同名同类型属性)
private static func copyProp(targetProps: ArrayList<InstancePropertyInfo>, sp: InstancePropertyInfo,
source: Any, target: Any): Unit {
for (tp in targetProps) {
if (tp.name == sp.name && tp.typeInfo == sp.typeInfo) {
tp.setValue(target, sp.getValue(source))
return
}
}
}
/// 收集类型(含继承链)的 public 实例属性,子类同名覆盖父类
private static func collectProps(typeInfo: TypeInfo): ArrayList<InstancePropertyInfo> {
let result = ArrayList<InstancePropertyInfo>()
collectPropsRecursive(typeInfo, result)
result
}
private static func collectPropsRecursive(typeInfo: TypeInfo, result: ArrayList<InstancePropertyInfo>): Unit {
if (let ct: ClassTypeInfo <- typeInfo) {
if (let Some(superType) <- ct.superClass) {
if (superType != TypeInfo.of<Object>()) {
collectPropsRecursive(superType, result)
}
}
for (p in ct.instanceProperties) {
var replaced = false
for ((index, existing) in result |> enumerate) {
if (existing.name == p.name) {
result[index] = p
replaced = true
break
}
}
if (!replaced) {
result.add(p)
}
}
}
}
}
-29
View File
@@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\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# 项目 SimApiE:\simcu\simapi-net)。
*/
public class SimApi {
private init() {}
/**
* 包版本号(编译期从 simapi-cj/cjpm.toml 读取)。
*/
@ReadTomlVersion[path: "../simapi-cj/cjpm.toml"]
public static let version: String = ""
}