Compare commits

...
11 Commits
Author SHA1 Message Date
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
22 changed files with 1684 additions and 105 deletions
+201 -32
View File
@@ -74,15 +74,20 @@ main(args: Array<String>) {
simapi-cj/
├── cjpm.toml # 包配置
├── src/
│ ├── communications/ # SimApiBaseResponse, PageResponse, SimApiLoginItem, ApiResult, 请求 DTO
│ ├── attributes/ # 声明式注解:@SimApiAuth(鉴权)、@OriginResponse(原样响应)
│ ├── 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
│ ├── extensions/ # SimApiExtensionsaddSimApi / useSimApi + 内置路由 + 响应封装
│ ├── helpers/ # SimApiError, SimApiUtil, SimApiAuth, SimApiCache, SimApiHttpClient,
│ │ # SimApiAesUtil(AES-256), SimApiSignChecker(验签), SimApiAesBodyChecker(AES body)
│ ├── interfaces/ # ISimApiAuthChecker
│ ├── logger/ # SimApiLogger, SimApiLoggerProvider(彩色日志)
── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware
── macros/ # ReadTomlVersion(编译期读版本号)
│ ├── middlewares/ # SimApiExceptionMiddleware, SimApiAuthMiddleware, SimApiRequestLogMiddleware
│ └── models/ # SimApiBaseModel(实体基类)
```
---
@@ -106,7 +111,8 @@ SimApiError.errorWhenNone(someOptional, 404, "用户不存在")
import simapi.helpers.*
import 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 +120,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`),替代手动 `requireLogin()`
```cangjie
import simapi.attributes.{SimApiAuth}
@SimApiAuth // 类级:整个控制器需登录
public class MyController <: SimApiBaseController {
@SimApiAuth["admin"] // 方法级:仅 admin 类型可访问
@HttpPost["my/admin-only"]
public func adminOnly(): String { "ok" }
}
```
> 说明:仓颉注解参数须为编译期常量,`@SimApiAuth` 支持单类型参数(`@SimApiAuth["admin"]`);空参数表示任意已登录用户。多个 `ISimApiAuthChecker` 通过 `SimApiOptions.authCheckers` 注册(由 addSimApi 扫描调用者包填充)。
### 2.2 原样响应 — @OriginResponse
标注后跳过统一响应封装,接口返回什么就输出什么(对齐 C# `[OriginResponse]`):
```cangjie
import simapi.attributes.{OriginResponse}
@OriginResponse
@HttpGet["raw"]
public func raw(): String {
"{\"raw\":true}" // 直接输出,不包 {code,message,data}
}
```
### 2.3 服务端验签 — SimApiSignChecker(对齐 C# [SimApiSign]
校验带签名请求(appId 提取 → 密钥获取 → timestamp 过期校验 → nonce 去重 → MD5 比对):
```cangjie
import simapi.helpers.{SimApiSignProviderBase, SimApiSignChecker}
// 1. 继承 Provider 实现密钥获取
public class MySignProvider <: SimApiSignProviderBase {
public override func getKey(appId: ?String): ?String {
// 根据 appId 返回密钥(如查库)
Some("my-secret-key")
}
}
// 2. 控制器方法开头调用校验
public func signedAction(): String {
SimApiSignChecker.verify(context, provider, cache)
"ok"
}
```
Provider 可配置:`appIdName` / `timestampName` / `nonceName` / `signName` / `queryExpires` / `duplicateRequestProtection` / `signFields`(与 C# `SimApiSignProviderBase` 一致)。
### 2.4 AES body 解密 — SimApiAesBodyChecker(对齐 C# [AesBody]
服务端接收 `{"data":"密文"}` 加密 body,解密后返回明文 JSON(控制器再反序列化为目标类型):
```cangjie
import simapi.helpers.{AesBodyProviderBase, SimApiAesBodyChecker}
public class MyAesProvider <: AesBodyProviderBase {
public override func getKey(appId: ?String): ?String {
Some("aes-secret-key")
}
}
public func create(@FromBody req: AesBodyRequest): String {
let json = SimApiAesBodyChecker.decryptBody(context, provider) // 解密后的 JSON 字符串
let dto = JsonSerializer.deserializeObject<MyDto>(json)
"ok"
}
```
### 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,16 +221,47 @@ 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.fromJson<T>(json) // JSON → T(对齐 C# FromJson<T>T 需 ISerialization<T>
SimApiUtil.base64DecodeTo<T>(str) // Base64 → JSON → T(对齐 C# Base64Decode<T>
SimApiUtil.checkCell("13800138000") // 手机号
SimApiUtil.checkEmail("a@b.com") // 邮箱
```
### 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 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 服务**内置 TLS 支持**`https` 自动配置信任所有证书 + SNI,仓颉生态下 stdx TLS 动态加载 openssl 可用)
```cangjie
let client = SimApiHttpClient(options: SimApiHttpClientOptions()) // 配置 server/appId/appKey
@@ -155,15 +272,20 @@ let resp2 = client.aesQuery<SimApiLoginItem>("/api/data", body: "{\"a\":1}")
let resp3 = client.aesSignQuery<SimApiLoginItem>("/api/data", body: "{\"a\":1}")
```
签名参数名可配置(`simApiHttpClientOptions.signName / timestampName / nonceName / appIdName / signFields`C# 侧为硬编码)。
### 5.1 请求日志 — enableRequestLog
记录每次请求的方法、URL、请求头、请求体、响应状态码耗时:
记录每次请求的方法、URL、请求头、请求体、响应状态码耗时与异常(对齐 C#
- 请求体按 **JSON 字段级截断**(仅对超长字符串字段截断,保留结构;非 JSON 整串截断)
- 下游异常**捕获记录后重抛**(对齐 C# ExceptionDispatchInfo
- 响应体因 soulsoft `HttpResponse.body` 只读不可替换,记录 `Content-Length` 作为替代(C# 用 MemoryStream 捕获)
```cangjie
builder.addSimApi { options =>
options.enableRequestLog = true
options.simApiRequestLogOptions.showFullHeader = true // 打印完整 Header(默认只打 Token/Query-Id
options.simApiRequestLogOptions.requestStringLogLength = 200 // 请求体截断长度(0 不截断)
options.simApiRequestLogOptions.requestStringLogLength = 200 // 请求体字段截断长度(0 不截断)
}
```
@@ -174,7 +296,7 @@ builder.addSimApi { options =>
*( RequestHeaders [Full] ) =>
{"host":"127.0.0.1:5000",...}
*( RequestBody ) =>
{"name":"AAAA...(200)","image":"x"}
*( Response [200] ) => 1.756400ms
```
@@ -207,8 +329,20 @@ builder.addSimApi { options =>
| `/auth/logout` | POST | `enableSimApiAuth` | 退出登录 |
| `/exception/{code}` | GET | 始终 | 错误反馈 |
路由路径可自定义(`configureSimApiRoute`,自定义值通过 `mapGet/mapPost` 真实注册,默认值由内置控制器特性路由覆盖):
```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.*
@@ -219,13 +353,45 @@ class MyAuthChecker <: ISimApiAuthChecker {
}
```
### 8. 认证中心 SDK — AuthSDK(对齐 C# AuthSDK
`enableSimApiAuthGate = true` 时注册 `SimApiAuthClient` / `SimApiAuthCenter` / `SimApiAuthIam` 单例并挂载网关透传中间件:
```cangjie
builder.addSimApi { 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 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(可选)
options.redisConfiguration = "localhost:6379" // Redis(可选,支持 ,password=xxx,db=2
// 功能开关
options.enableSimApiAuth = false // Token 认证
@@ -233,14 +399,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 +420,57 @@ 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()` 扫描,见 `SimApiControllerScanner`):
| 控制器 | 路由 | 说明 |
|--------|------|------|
| `SimApiCommonController` | `/exception/{code}``/webconfig``/user/info` | 通用内置路由 |
| `SimApiCommonController` | `/exception/{code}``/config``/versions``/user/info` | 通用内置路由 |
| `SimApiAuthController` | `/auth/logout` | 退出登录 |
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` |
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` / `getLogin()` |
```cangjie
import simapi.controllers.*
import simapi.attributes.{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/MinIO/Swashbuckle**选项保留但未实现**
| 选项 | 原功能 | 状态 |
|------|--------|------|
| `enableSimApiDoc` | Swagger 文档 | ❌ 未实现 |
| `enableSimApiDoc` | Swagger 文档(可换 soulsoft_web_openapi | ❌ 未实现 |
| `enableSimApiStorage` | S3/MinIO 存储 | ❌ 未实现 |
| `enableSynapse` | MQTT 通信 | ❌ 未实现 |
| `enableJob` | Hangfire 任务调度 | ❌ 未实现 |
| `enableSimApiAuthGate` | Auth Center 网关鉴权 | ❌ 未实现 |
| `SimApiAesUtil` | AES-256-CBC | ⚠️ 仓颉 std 无 AES,暂用 Base64 占位 |
> ✅ 已实现(曾为占位):`enableSimApiAuthGate`AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC,与 .NET 双向互操作)、`ISimApiAuthChecker`(注解鉴权时执行)、内置路由自定义路径。
---
+12 -12
View File
@@ -2,19 +2,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_identity_claims = {version = "1.0.20260528"}
soulsoft_web_routing = {version = "1.0.20260528"}
soulsoft_web_hosting = {version = "1.0.20260528"}
soulsoft_extensions_logging = {version = "1.0.20260528"}
soulsoft_extensions_configuration = {version = "1.0.20260528"}
soulsoft_web_mvc = {version = "1.0.20260528"}
soulsoft_extensions_logging_console = {version = "1.0.20260528"}
soulsoft_web_cors = {version = "1.0.20260528"}
soulsoft_extensions_logging_configuration = {version = "1.0.20260528"}
soulsoft_extensions_injection = {version = "1.0.20260528"}
soulsoft_serialization = {version = "1.0.20260528"}
soulsoft_extensions_options = {version = "1.0.20260528"}
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.attributes
/**
* 原样响应注解(对齐 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() {}
}
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.attributes
/**
* 声明式鉴权注解(对齐 C# SimApi.Attributes.SimApiAuthAttribute)。
*
* 标注在控制器方法或类上,请求派发时(SimApiRequestDelegateFactory)自动执行鉴权:
* - 未登录(无 LoginInfo)→ 401
* - type 非空且登录用户类型不匹配 → 403
* - 遍历执行所有已注册的 ISimApiAuthChecker
*
* 用法:
* @SimApiAuth // 任意已登录用户
* @SimApiAuth["admin"] // 仅 admin 类型
*
* 说明:仓颉注解参数须为编译期常量,且 String 无法作为 const 值数组元素
* (内部为 Array<UInt8>),故与 C# 的 string[] 不同,这里支持单个类型参数。
*/
@Annotation[target: [MemberFunction, Type]]
public class SimApiAuth {
/// 允许访问的用户类型(空 = 任意已登录用户)
public let `type`: String
public const init() {
this.`type` = ""
}
public const init(`type`: String) {
this.`type` = `type`
}
}
+234
View File
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK/SimApiAuthCenter:认证中心远程 SDK。
*/
package simapi.authsdk
import std.collection.*
import stdx.net.tls.*
import stdx.net.tls.common.*
import soulsoft_net_http.{HttpClient, HttpRequestMessage, JsonContent}
import soulsoft_net_http.{HttpMethod as NetHttpMethod}
import simapi.communications.*
import 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 = HttpClient.create { builder =>
builder.noProxy()
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
match (_client.server.indexOf("://")) {
case Some(i) =>
let rest = _client.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 => ()
}
builder.tlsConfig(tls)
}
try {
let request = HttpRequestMessage(NetHttpMethod.Post, url)
request.content = JsonContent.create("{}")
let response = http.send(request)
try {
response.ensureSuccessStatusCode()
let resp = response.content.readFromJson<SimApiBaseResponse>()
SimApiError.errorWhen(resp._code != 200, code: 400, message: "签名验证失败")
} finally {
response.close()
}
} finally {
http.close()
}
}
// ===== 群组相关 =====
/**
* 根据 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: SimApiJson.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: SimApiJson.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: SimApiJson.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: SimApiJson.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: SimApiJson.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: SimApiJson.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: SimApiJson.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: SimApiJson.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
let resp = _client.signQuery<LoginInfoResponse>("/api/auth/login/get", body: SimApiJson.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: SimApiJson.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: SimApiJson.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}\":\"${SimApiJson.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 simapi.authsdk
import soulsoft_web_http.*
import simapi.communications.*
import simapi.configurations.*
import 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 simapi.authsdk
import simapi.configurations.*
import 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
}
}
+122
View File
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* AuthSDK 用到的 DTO(对齐 C# SimApiAuthCenterDto / SimApiAuthIamDto)。
*/
package simapi.authsdk
import std.collection.*
import stdx.encoding.json.*
import soulsoft_serialization.*
import soulsoft_serialization.macros.*
/**
* 应用/Profile 通用项(对齐 C# AppAndProfileItem)。
*/
@Serialization
public class AppAndProfileItem {
public var _id: String = ""
public var _name: String = ""
public var _image: ?String = None
public var _description: ?String = None
public init() {}
}
/**
* 安全确认响应(对齐 C# ConfirmResponse)。
*/
@Serialization
public class ConfirmResponse {
public var _applicationId: String = ""
public var _profileId: String = ""
public var _scene: ?String = None
public var _data: ?JsonValue = None
public init() {}
}
/**
* 登录信息响应(对齐 C# LoginInfoResponse)。
*/
@Serialization
public class LoginInfoResponse {
public var _scene: ?String = None
public var _data: ?JsonValue = None
public var _profileId: String = ""
public var _name: String = ""
public var _image: ?String = None
public var _description: ?String = None
public init() {}
}
/**
* 获取授权码响应(对齐 C# GetCodeResponse)。
*/
@Serialization
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)。
*/
@Serialization
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
public init() {}
}
/**
* 群组详情树节点(对齐 C# GroupDetailTreeNodechildren 递归)。
*/
@Serialization
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> = []
public init() {}
}
/**
* 权限项(对齐 C# PermissionItem)。
*/
@Serialization
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 simapi.authsdk
import std.collection.*
import simapi.communications.*
import 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: SimApiJson.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: SimApiJson.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: SimApiJson.json(Some(body)))
SimApiError.errorWhen(!ok, code: 403, message: "没有该权限")
}
}
+6
View File
@@ -7,11 +7,17 @@
package simapi.configurations
import std.collection.*
import std.reflect.*
/**
* SimApi 全局配置:对应 C# 的 SimApi.Configurations.SimApiOptions。
*/
public class SimApiOptions {
/**
* 已注册的 ISimApiAuthChecker 类型列表(由 addSimApi 扫描调用者程序集填充,
* 运行时在 @SimApiAuth 鉴权时逐个从 DI 解析执行,对齐 C# AddScoped 扫描)。
*/
public var authCheckers: ArrayList<TypeInfo> = ArrayList<TypeInfo>()
/**
* Redis 配置;配置则使用 Redis,不配则自动使用 InMemory。
*/
+73 -5
View File
@@ -20,6 +20,7 @@ import soulsoft_web_cors.*
import soulsoft_web_routing.*
import soulsoft_extensions_injection.*
import soulsoft_extensions_logging.*
import simapi.authsdk.*
import simapi.communications.*
import simapi.configurations.*
import simapi.controllers.*
@@ -114,6 +115,17 @@ private func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): Web
builder.services.addSingleton<SimApiAuth, SimApiAuth>()
}
// ISimApiAuthChecker 自动扫描注册(对齐 C# AddSimApi 中遍历调用者程序集 AddScoped 注册 checker
// 扫描调用者包中的 ISimApiAuthChecker 实现类:填充 options.authCheckers(供 @SimApiAuth 执行时解析)
// 并注册到 DI(按实现类类型注册,供 getOrThrow(checkerType) 解析)
if (options.enableSimApiAuth) {
let checkers = SimApiControllerScanner.scanAuthCheckers()
for (checkerType in checkers) {
options.authCheckers.add(checkerType)
builder.services.addScoped(checkerType)
}
}
// 缓存(DI 自动注入 SimApiOptions
if (options.enableSimApiCache) {
builder.services.addSingleton<SimApiCache, SimApiCache>()
@@ -124,6 +136,13 @@ private func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): Web
builder.services.addSingleton<SimApiHttpClient, SimApiHttpClient>()
}
// 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 {
@@ -206,9 +225,10 @@ extend WebHost <: SimApiHostExtensions {
this.useCors()
}
// AuthGate占位,对齐 C# UseMiddleware<SimApiAuthCenterMiddleware>
// AuthGate(对齐 C# UseMiddleware<SimApiAuthCenterMiddleware>
if (options.enableSimApiAuthGate) {
logger.info("开始配置 SimApiAuthGate...")
this.use<SimApiAuthCenterMiddleware>()
}
// 认证中间件(对齐 C# builder.UseMiddleware<SimApiAuthMiddleware>()
@@ -229,14 +249,62 @@ extend WebHost <: SimApiHostExtensions {
this.use<SimApiExceptionMiddleware>()
}
// 内置路由
if (let Some(route) <- options.simApiRouteOptions.userInfoRoute) {
// 内置路由RouteOptions 自定义路径时真实注册(默认路径已由内置控制器特性路由覆盖,
// 对齐 C# MapControllerRoute 语义;soulsoft 无约定路由 defaults,用 mapGet/mapPost 委托实现)
let routeOptions = options.simApiRouteOptions
if (let Some(route) <- routeOptions.userInfoRoute) {
if (route != "/user/info") {
this.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) <- options.simApiRouteOptions.logoutRoute) {
if (let Some(route) <- routeOptions.logoutRoute) {
if (route != "/auth/logout") {
this.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) <- options.simApiRouteOptions.webConfigRoute) {
if (let Some(route) <- routeOptions.webConfigRoute) {
if (route != "/config") {
this.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())
}
})
this.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}")
}
+70 -17
View File
@@ -23,8 +23,12 @@ import soulsoft_web_mvc.modelBindings.*
import soulsoft_web_mvc.abstractions.*
import soulsoft_extensions_options.*
import soulsoft_extensions_injection.*
import simapi.attributes.{SimApiAuth, OriginResponse}
import simapi.communications.*
import simapi.configurations.*
import simapi.controllers.*
import simapi.helpers.{SimApiError}
import simapi.interfaces.*
/**
* 自定义请求委托工厂:接管 soulsoft 的 ControllerRequestDelegateFactory
@@ -57,6 +61,7 @@ struct SimApiActionInvoker {
public func apply(): Unit {
let controller = createControllerInstance()
checkSimApiAuth()
let modelBindingContext = ActionBindingContext(context, actionDescriptor.actionFunction.parameters)
let boundParameters = modelBinder.bind(modelBindingContext)
if (!modelBindingContext.modelState.isValid) {
@@ -67,6 +72,52 @@ struct SimApiActionInvoker {
}
}
/// 检查 @SimApiAuth 注解并执行鉴权(对齐 C# SimApiAuthAttribute.OnActionExecuting):
/// 未登录 401 → 类型权限 403 → 遍历执行 ISimApiAuthChecker
private func checkSimApiAuth() {
var auth: ?SimApiAuth = None
for (item in actionDescriptor.endpointMetadata) {
if (let a: SimApiAuth <- 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. 类型权限校验 → 403
if (!auth.`type`.isEmpty()) {
if (!loginItem._types.contains(auth.`type`)) {
SimApiError.error(code: 403, message: "无权访问")
}
}
// 3. 遍历执行 ISimApiAuthChecker(从 DI 按注册类型解析)
let token = match (context.items.get("LoginToken")) {
case Some(v) => if (let s: String <- v) { s } else { "" }
case None => ""
}
let options = context.services.getOrThrow<SimApiOptions>()
for (checkerType in options.authCheckers) {
let instance = context.services.getOrThrow(checkerType)
if (let checker: ISimApiAuthChecker <- instance) {
checker.run(loginItem, token)
}
}
}
}
/// 模型绑定失败时写入 ProblemDetails 响应
private func handleInvalidModelState(modelBindingContext: ActionBindingContext) {
let options = context.services.getOrThrow<IOptions<ApiBehaviorOptions>>()
@@ -84,24 +135,26 @@ struct SimApiActionInvoker {
/// 结果派发 + 自动封装(对齐 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)
// @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"
context.response.write(s)
} else {
ObjectResult<Any>(actionResult).invoke(context)
}
return
}
// 统一封装(IActionResult/SimApiBaseResponse/String/Unit/其他对象)
SimApiResultWriter.write(context, actionResult)
}
/// 根据 ModelState 错误构建 ValidationProblemDetails
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.extensions
import soulsoft_web_http.*
import soulsoft_web_mvc.core.*
import simapi.communications.*
/**
* 统一响应封装工具(对齐 C# SimApiResponseFilter 的包装分支)。
* 供 SimApiRequestDelegateFactory 与内置路由委托复用:
* - SimApiBaseResponse(含子类)→ 原样输出
* - String → SimApiResponse<String>data 为字符串)
* - Unitvoid)→ SimApiBaseResponse(){code:200, message:成功}
* - 其他对象(DTO/数组/动态结构)→ SimApiDataResponsedata 内嵌为对象)
*/
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(含子类)→ 原样输出
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 内嵌为对象
ObjectResult<Any>(SimApiDataResponse(actionResult)).invoke(context)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
import std.collection.*
import std.io.*
import soulsoft_serialization.*
import soulsoft_serialization.macros.*
import soulsoft_web_http.*
import simapi.communications.*
import simapi.exceptions.*
/**
* AES body 请求({"data": "密文"},对齐 C# SimApiOneFieldRequest<string>)。
*/
@Serialization
public class AesBodyRequest {
public var _data: String = ""
public init() {}
}
/**
* AES body 密钥提供器(对齐 C# ModelBinders/AesBodyProviderBase):
* 应用继承本类并实现 getKey(appId),返回 appId 对应的 AES 密钥。
*/
public open class AesBodyProviderBase {
/// appId 字段名(None 表示不带 appId
public var appIdName: ?String = Some("appId")
public init() {}
/**
* 根据 appId 获取密钥。
* @param appId 应用 ID(未配置 appIdName 时为 None)。
* @return 密钥;返回 None 表示获取失败。
*/
public open func getKey(appId: ?String): ?String {
None
}
}
/**
* 服务端 AES body 解密校验器(对齐 C# ModelBinders/AesBodyModelBinder)。
*
* 仓颉无 ModelBinder 机制,按项目惯例由控制器在方法开头调用:
* let jsonStr = SimApiAesBodyChecker.decryptBody(context, provider)
* let request = JsonSerializer.deserializeObject<XxxRequest>(jsonStr)
*
* 流程(与 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.deserializeObject<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)
}
}
+84 -14
View File
@@ -8,6 +8,7 @@ package simapi.helpers
import std.collection.*
import std.collection.concurrent.*
import std.convert.*
import std.time.*
import stdx.encoding.json.*
import soulsoft_serialization.*
import redis.client.*
@@ -18,9 +19,21 @@ import 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 +42,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 +54,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)
}
}
@@ -66,7 +93,7 @@ public class SimApiAuth {
redis.sadd(setKey, [Blob.fromUtf8(newToken)])
redis.expire(setKey, expireSeconds)
} else {
_tokenStore[newToken] = json
_tokenStore[newToken] = TokenEntry(json, nowMillis() + expireSeconds * 1000)
var tokens = _userTokens.get(loginItem._id)
if (tokens == None) {
tokens = HashSet<String>()
@@ -89,7 +116,12 @@ public class SimApiAuth {
if (let Some(redis) <- _redis) {
redis.set(tokenKey, Blob.fromUtf8(json))
} 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,7 +140,17 @@ 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))
@@ -202,12 +244,40 @@ 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 {
+76 -12
View File
@@ -8,19 +8,32 @@ package simapi.helpers
import std.collection.*
import std.collection.concurrent.*
import std.convert.*
import std.time.*
import redis.client.*
import simapi.configurations.*
import 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 +42,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,7 +64,7 @@ public class SimApiCache {
* 设置缓存。
* @param key 缓存键。
* @param value 缓存值(不能为 null)。
* @param expireSeconds 过期秒数(可选)。
* @param expireSeconds 过期秒数(可选<=0 表示不过期)。
*/
public func set(key: String, value: String, expireSeconds!: Int64 = -1): Unit {
if (let Some(redis) <- _redis) {
@@ -48,7 +74,8 @@ public class SimApiCache {
redis.set("${prefix}${key}", Blob.fromUtf8(value))
}
} else {
_store["${prefix}${key}"] = value
let expireAt = if (expireSeconds > 0) { nowMillis() + expireSeconds * 1000 } else { 0 }
_store["${prefix}${key}"] = CacheEntry(value, expireAt)
}
}
@@ -80,14 +107,51 @@ public class SimApiCache {
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()
}
}
+48
View File
@@ -15,6 +15,7 @@ import std.collection.*
import std.core.*
import std.reflect.*
import soulsoft_web_mvc.core.*
import 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 找调用程序集)。
@@ -101,4 +114,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
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ import simapi.exceptions.*
* - 返回泛型 T(反序列化响应 body 的 data 字段),不再返回 String
* @param T 响应 data 的数据类型(需实现 ISerialization<T>,如 SimApiLoginItem、String、Int64 等)。
*/
public class SimApiHttpClient {
public open class SimApiHttpClient {
public var server: String
public var appId: String
public var appKey: String
+168
View File
@@ -0,0 +1,168 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.helpers
import std.convert.*
import soulsoft_web_http.*
import simapi.exceptions.*
/**
* 签名提供器(对齐 C# ModelBinders/SimApiSignProviderBase):
* 应用继承本类并实现 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> = []
public init() {}
/**
* 根据 appId 获取密钥。
* @param appId 应用 ID(未配置 appIdName 时为 None)。
* @return 密钥;返回 None 表示获取失败。
*/
public open func getKey(appId: ?String): ?String {
None
}
}
/**
* 服务端验签校验器(对齐 C# Attributes/SimApiSignAttribute.OnActionExecuting)。
*
* 仓颉无声明式 ActionFilter 机制,按项目惯例(同 requireLogin)由控制器在需要验签的方法开头调用:
* SimApiSignChecker.verify(context, provider, cache)
*
* 校验流程(与 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)
}
}
+36
View File
@@ -11,7 +11,9 @@ import std.random.*
import stdx.crypto.digest.*
import stdx.encoding.hex.*
import stdx.encoding.base64.*
import stdx.encoding.json.*
import std.regex.*
import soulsoft_serialization.*
import simapi.communications.*
import simapi.macros.*
@@ -153,4 +155,38 @@ public class SimApiUtil {
public static func json(obj: ?Any): String {
SimApiJson.json(obj)
}
/**
* 从 JSON 字符串反序列化为 T(对齐 C# SimApiUtil.FromJson<T>)。
* @param T 目标类型(需实现 ISerialization<T>,如 @Serialization DTO、基础类型等)。
* @param jsonString JSON 字符串。
* @return 反序列化结果。
*/
public static func fromJson<T>(jsonString: String): T where T <: ISerialization<T> {
JsonSerializer.deserializeObject<T>(jsonString)
}
/**
* 对象 Base64 编码(对象 → JSON → Base64,对齐 C# Base64Encode(object))。
* @param obj 任意对象(DTO/基础类型/HashMap 等)。
* @return Base64 字符串。
*/
public static func base64Encode(obj: Any): String {
let json = if (let ser: ISerializable <- obj) {
ser.serializeObject().toJson().toString()
} else {
SimApiJson.json(Some(obj))
}
base64Encode(json)
}
/**
* Base64 → JSON → T 反序列化(对齐 C# Base64Decode<T>)。
* @param T 目标类型(需实现 ISerialization<T>)。
* @param base64Str Base64 字符串。
* @return 反序列化结果。
*/
public static func base64DecodeTo<T>(base64Str: String): T where T <: ISerialization<T> {
fromJson<T>(base64Decode(base64Str))
}
}
+59 -12
View File
@@ -8,14 +8,21 @@ package simapi.middlewares
import std.collection.*
import std.io.*
import std.time.*
import stdx.encoding.json.*
import soulsoft_web_http.*
import soulsoft_extensions_logging.*
import simapi.communications.*
import simapi.configurations.*
/**
* 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码耗时。
* 请求日志中间件:记录请求方法、URL、请求头、请求体、响应状态码耗时与异常
* 对应 C# 的 SimApi.Middlewares.SimApiRequestLogMiddleware。
*
* 对齐说明:
* - 请求体按 JSON 字段级截断(对齐 C#:仅对超长字符串字段截断,保留结构)
* - 捕获下游异常并记录,随后重抛(对齐 C# ExceptionDispatchInfo + edi.Throw
* - 响应体:soulsoft HttpResponse.body 只读不可替换(C# 用 MemoryStream 替换捕获),
* 此处以 Content-Length 作为替代信息;ShowFullResponse 选项因此暂不生效
*/
public class SimApiRequestLogMiddleware <: IMiddleware {
private let _options: SimApiOptions
@@ -51,15 +58,33 @@ 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)
}
// 响应信息
let elapsed = MonoTime.now() - start
let elapsedMs = elapsed / Duration.millisecond
sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms\n")
sb.append("*( Response [${context.response.statusCode}] ) => ${elapsedMs}ms")
// 响应体捕获受 soulsoft 限制(body 只读不可替换),记录 Content-Length 作为替代
if (let Some(len) <- context.response.contentLength) {
sb.append(" (响应体长度: ${len})")
}
sb.append("\n")
if (let Some(ex) <- exception) {
sb.append("Exception: ${ex.toString()}\n")
}
_logger.info(sb.toString())
// 重抛原异常(对齐 C# edi?.Throw()),由外层 ExceptionMiddleware 处理
if (let Some(ex) <- exception) {
throw ex
}
}
private func serializeHeaders(context: HttpContext): String {
@@ -107,17 +132,39 @@ public class SimApiRequestLogMiddleware <: IMiddleware {
}
}
/// 请求体截断: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 {
let jv = JsonValue.fromStr(body)
match (jv.kind()) {
case JsObject =>
let obj = jv.asObject()
let newObj = JsonObject()
for ((k, v) in obj.getFields()) {
match (v.kind()) {
case JsString =>
let str = v.asString().getValue()
if (str.size > maxLen) {
newObj.put(k, JsonString(str[0..maxLen] + "...(${str.size})"))
} else {
newObj.put(k, v)
}
case _ => newObj.put(k, v)
}
}
return newObj.toString() + "\n"
case _ => ()
}
} catch (_: Exception) {
}
return "${sb.toString()}...(${body.size})\n"
// 非 JSON 或解析失败:整串按长度截断
if (body.size <= maxLen) {
return body + "\n"
}
return body[0..maxLen] + "...(${body.size})\n"
}
}
+120
View File
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
*/
package simapi.models
import std.collection.*
import std.reflect.*
import std.time.*
import 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"
public init() {}
/**
* 反射映射:把 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)
}
}
}
}
}