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>
This commit is contained in:
2026-08-16 22:46:57 +08:00
parent a73a986f8b
commit 8a6c783459
3 changed files with 196 additions and 26 deletions
+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()
}
}
+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))
}
}