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 同步更新
This commit is contained in:
2026-08-16 23:44:52 +08:00
parent 40e0726087
commit 92eea3108a
17 changed files with 142 additions and 76 deletions
+1
View File
@@ -1 +1,2 @@
target/
*.cj.macrocall
+5 -6
View File
@@ -102,7 +102,7 @@ import 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
@@ -143,7 +143,7 @@ public class MyController <: SimApiBaseController {
}
```
> 说明:仓颉注解参数须为编译期常量,`@SimApiAuth` 支持单类型参数(`@SimApiAuth["admin"]`);空参数表示任意已登录用户。多个 `ISimApiAuthChecker` 通过 `SimApiOptions.authCheckers` 注册(由 addSimApi 扫描调用者包填充)。
> 说明:仓颉注解参数须为编译期常量,`@SimApiAuth` 支持单类型参数(`@SimApiAuth["admin"]`或逗号分隔多类型(`@SimApiAuth["admin,user"]`,对齐 C# `type.Split(",")`;空参数表示任意已登录用户。多个 `ISimApiAuthChecker` 通过 `SimApiOptions.authCheckers` 注册(由 addSimApi 扫描调用者包填充)。
### 2.2 原样响应 — @OriginResponse
@@ -272,7 +272,7 @@ 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# 侧为硬编码)。
签名参数名可配置(`SimApiHttpClient` 实例属性 `signName / timestampName / nonceName / appIdName / signFields`对齐 C# 的 virtual 属性)。
### 5.1 请求日志 — enableRequestLog
@@ -324,10 +324,9 @@ builder.addSimApi { options =>
| 路由 | 方法 | 条件 | 说明 |
| ----------------- | -------- | ---------------------------- | -------------------------- |
| `/versions` | GET/POST | 始终 | 返回 SimApi/App 版本 |
| `/user/info` | POST | `enableSimApiAuth` | 需登录,返回 LoginInfo |
| `/auth/logout` | POST | `enableSimApiAuth` | 退出登录 |
| `/exception/{code}` | GET | 始终 | 错误反馈 |
| `/exception/{code}` | GET | 始终 | 错误反馈(抛 SimApiException |
路由路径可自定义(`configureSimApiRoute`,自定义值通过 `mapGet/mapPost` 真实注册,默认值由内置控制器特性路由覆盖):
@@ -434,7 +433,7 @@ simapi 提供 Spire MVC 控制器(继承 `SimApiBaseController`),`addSimAp
| 控制器 | 路由 | 说明 |
|--------|------|------|
| `SimApiCommonController` | `/exception/{code}``/config``/versions``/user/info` | 通用内置路由 |
| `SimApiCommonController` | `/exception/{code}``/config``/user/info` | 通用内置路由 |
| `SimApiAuthController` | `/auth/logout` | 退出登录 |
| `SimApiBaseController` | — | 基类:`loginInfo` / `loginToken` / `requireLogin()` / `getLogin()` |
+3 -2
View File
@@ -16,9 +16,10 @@ package simapi.attributes
* 用法:
* @SimApiAuth // 任意已登录用户
* @SimApiAuth["admin"] // 仅 admin 类型
* @SimApiAuth["admin,user"] // admin 或 user 类型(对齐 C# type.Split(",")
*
* 说明:仓颉注解参数须为编译期常量,String 无法作为 const 值数组元素
* (内部为 Array<UInt8>),故与 C# 的 string[] 不同,这里支持单个类型参数
* 说明:仓颉注解参数须为编译期常量,String 无法作为 const 值数组元素
* 故与 C# 的 string[] 不同,这里用逗号分隔字符串对齐 C# 多类型
*/
@Annotation[target: [MemberFunction, Type]]
public class SimApiAuth {
+2
View File
@@ -193,6 +193,8 @@ public class SimApiAuthCenter {
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: SimApiJson.json(Some(body)))
SimApiError.errorWhen(resp._scene != scene, code: 403003, message: "登录场景不匹配")
resp
+5
View File
@@ -2,6 +2,10 @@
* 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),
* 故拍平为顶层类,语义与字段保持一致。
*/
package simapi.authsdk
@@ -26,6 +30,7 @@ public class AppAndProfileItem {
/**
* 安全确认响应(对齐 C# ConfirmResponse)。
* _data 用 ?JsonValue 对齐 C# Dictionary<string,object>?(任意 JSON 对象)。
*/
@Serialization
public class ConfirmResponse {
+13
View File
@@ -13,6 +13,19 @@ public class SimApiBaseRequest {}
/**
* 仅包含 Id 的请求。
*/
public class SimApiIdOnlyRequest {
public var id: Int64 = 0
public init() {}
public init(id: Int64) {
this.id = id
}
}
/**
* 仅包含 Id 的请求(字符串)。
*/
public class SimApiStringIdOnlyRequest {
public var id: String = ""
+1 -1
View File
@@ -20,7 +20,7 @@ import soulsoft_serialization.*
*/
public class SimApiLoginItem <: ISerialization<SimApiLoginItem> {
public var _id: String = ""
public var _types: Array<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>()
@@ -13,11 +13,6 @@ 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() {}
}
+3 -16
View File
@@ -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)
}
/**
+21 -21
View File
@@ -216,10 +216,15 @@ extend WebHost <: SimApiHostExtensions {
}
// ===== 中间件与路由(对齐 C# UseSimApi(WebApplication) 的挂载顺序) =====
// C# 挂载顺序(先挂载 = 外层):CORS(L425) → AuthGate(L454) → Auth(L462) → RequestLog(L519) → Exception(L525)
// OPTIONS 预检请求在 CORS 处短路(204,不调用 next),因此 RequestLog/Exception 均不会执行
// C# 挂载顺序(先挂载 = 外层):ForwardedHeaders(L419) → CORS(L425) → AuthGate(L454) → Auth(L462)
// → 内置路由(L465-496) → Swagger(L502) → RequestLog(L519) → Exception(L525) → LowerUrl → Job
// CORS(对齐 C# builder.UseCors("any")最先挂载)
// ForwardedHeaders(占位:soulsoft 暂无内置,对齐 C# 最先挂载)
if (options.enableForwardHeaders) {
logger.info("开始配置ForwardedHeaders...")
}
// CORS(对齐 C# builder.UseCors("any")
if (options.enableCors) {
logger.info("开始配置 Cors全部允许...")
this.useCors()
@@ -237,18 +242,6 @@ extend WebHost <: SimApiHostExtensions {
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>()
}
// 内置路由:RouteOptions 自定义路径时真实注册(默认路径已由内置控制器特性路由覆盖,
// 对齐 C# MapControllerRoute 语义;soulsoft 无约定路由 defaults,用 mapGet/mapPost 委托实现)
let routeOptions = options.simApiRouteOptions
@@ -308,11 +301,23 @@ extend WebHost <: SimApiHostExtensions {
logger.info("注册内置Route: WebConfig => ${route}")
}
// SimApiDoc(占位)
// SimApiDoc(占位,对齐 C# UseSwagger/UseSwaggerUI
if (options.enableSimApiDoc) {
logger.info("开始配置 SimApiDoc...")
}
// 请求日志中间件(对齐 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>()
}
// URL 小写
if (options.enableLowerUrl) {
logger.info("开始配置使用URL小写...")
@@ -329,11 +334,6 @@ extend WebHost <: SimApiHostExtensions {
logger.info("开始配置 SimApiResponseFilter...")
}
// ForwardedHeaders(占位:soulsoft 暂无内置)
if (options.enableForwardHeaders) {
logger.info("开始配置ForwardedHeaders...")
}
// 映射控制器端点(对齐 C# UseSimApi 中的 MapControllers
let callSiteFactory = this.services.getOrThrow<IServiceProviderIsService>()
if (callSiteFactory.isService<ApplicationPartManager>()) {
+10 -2
View File
@@ -96,9 +96,17 @@ struct SimApiActionInvoker {
SimApiError.error(code: 401, message: "需要登录")
}
// 2. 类型权限校验 → 403
// 2. 类型权限校验 → 403(对齐 C# Types.Intersect(loginInfo.Type).Any(),支持逗号分隔多类型)
if (!auth.`type`.isEmpty()) {
if (!loginItem._types.contains(auth.`type`)) {
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: "无权访问")
}
}
+5
View File
@@ -114,7 +114,12 @@ 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 {
// 保留原过期时间(对齐 C# update 不刷新 TTL
let expireAt = match (_tokenStore.get(token)) {
+19 -7
View File
@@ -10,6 +10,7 @@ import std.collection.concurrent.*
import std.convert.*
import std.time.*
import redis.client.*
import soulsoft_serialization.*
import simapi.configurations.*
import simapi.exceptions.*
@@ -66,16 +67,17 @@ public class SimApiCache {
* @param value 缓存值(不能为 null)。
* @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 {
let expireAt = if (expireSeconds > 0) { nowMillis() + expireSeconds * 1000 } else { 0 }
_store["${prefix}${key}"] = CacheEntry(value, expireAt)
_store["${prefix}${key}"] = CacheEntry(json, expireAt)
}
}
@@ -94,13 +96,23 @@ public class SimApiCache {
* 缓存 Key 是否存在。
*/
public func hasKey(key: String): Bool {
get(key) != None
getString(key) != None
}
/**
* 获取 string 类型缓存
* 获取特定类型缓存(对齐 C# Get<T>:从 JSON 反序列化)
*/
public func get(key: String): ?String {
public func get<T>(key: String): ?T where T <: ISerialization<T> {
match (getString(key)) {
case Some(json) => Some(JsonSerializer.deserializeObject<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())
+1 -1
View File
@@ -55,7 +55,7 @@ public class SimApiError {
* @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 _ => ()
-5
View File
@@ -38,11 +38,6 @@ public open 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
}
/**
+45 -5
View File
@@ -61,10 +61,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)
}
/**
@@ -72,10 +72,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)
}
/**
@@ -89,6 +89,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 编码。
*/
@@ -112,7 +134,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)
@@ -189,4 +212,21 @@ public class SimApiUtil {
public static func base64DecodeTo<T>(base64Str: String): T where T <: ISerialization<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 序列化库,未移植。
}
+7 -4
View File
@@ -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}"