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:
+84
-14
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user