Prechádzať zdrojové kódy

商家后台登录态改用 Redis 存储,不再兼容 PHP File 缓存

- 新增 RedisTokenStore:key = yoshop:store:token:{token},value 为登录态 JSON,默认 TTL 7 天
- 新增 RedisUtils / RedisConfig / RedisConfigCacheManager(RedisTemplate<String,Object>,value 用 Jackson 序列化)
- 删除 ThinkFileCacheTokenStore(PHP ThinkPHP File 缓存兼容实现)
- StoreAuthInterceptor 改为注入 RedisTokenStore,鉴权模式只保留 off / redis
- StoreUserService:登录写入、登出删除、改资料刷新时保留剩余 TTL
- RedisTokenStore.getLoginInfo 先判空再 toString,避免 key 不存在时 NPE
- application.yml:auth.mode 默认 redis,新增 token-prefix / token-ttl,移除 runtime-cache-path
- 新增 tools/redis_probe.py、tools/test_redis_auth.py 用于连通性与端到端验证
- 同步更新 README 认证章节、联调说明、第 8 批文档的启动参数
tanlie 5 hodín pred
rodič
commit
d33b8fac8f

+ 63 - 0
tools/redis_probe.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+"""最小 RESP 客户端,用于验证 Redis 连通性与键值(不依赖 redis-py)。"""
+import socket
+import sys
+
+
+def cmd(sock, *args):
+    data = b"*%d\r\n" % len(args)
+    for a in args:
+        b = a.encode() if isinstance(a, str) else a
+        data += b"$%d\r\n%s\r\n" % (len(b), b)
+    sock.sendall(data)
+    return read_reply(sock)
+
+
+def read_reply(sock):
+    line = readline(sock)
+    if not line:
+        return None
+    t, rest = line[:1], line[1:]
+    if t == b"+":
+        return rest.decode()
+    if t == b"-":
+        return Exception(rest.decode())
+    if t == b":":
+        return int(rest)
+    if t == b"$":
+        n = int(rest)
+        if n == -1:
+            return None
+        buf = b""
+        while len(buf) < n + 2:
+            buf += sock.recv(n + 2 - len(buf))
+        return buf[:n].decode()
+    if t == b"*":
+        n = int(rest)
+        if n == -1:
+            return None
+        return [read_reply(sock) for _ in range(n)]
+    return line
+
+
+def readline(sock):
+    buf = b""
+    while not buf.endswith(b"\r\n"):
+        c = sock.recv(1)
+        if not c:
+            return buf
+        buf += c
+    return buf[:-2]
+
+
+def main():
+    host, port, pwd = "192.168.1.177", 6379, "@@Qinys12346.."
+    s = socket.create_connection((host, port), timeout=5)
+    print("AUTH:", cmd(s, "AUTH", pwd))
+    print("PING:", cmd(s, "PING"))
+    print("DBSIZE:", cmd(s, "DBSIZE"))
+    s.close()
+
+
+if __name__ == "__main__":
+    main()

+ 155 - 0
tools/test_redis_auth.py

@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+"""验证 /store/passport/login 的 Redis 登录态:登录写入 → 鉴权通过 → 登出删除 → 鉴权失效。
+
+前置:Java 服务运行在 18080(yoshop.auth.mode=redis)。
+"""
+import json
+import socket
+import sys
+
+import requests
+
+BASE = "http://127.0.0.1:18080"
+REDIS = ("192.168.1.177", 6379, "@@Qinys12346..")
+PREFIX = "yoshop:store:token:"
+
+S = requests.Session()
+S.trust_env = False
+
+passed = failed = 0
+
+
+def check(name, cond, extra=""):
+    global passed, failed
+    if cond:
+        passed += 1
+        print("  [ok] %s" % name)
+    else:
+        failed += 1
+        print("  [FAIL] %s %s" % (name, extra))
+
+
+# ---------------------------------------------------------------- 最小 Redis 客户端
+def resp(sock, *args):
+    data = b"*%d\r\n" % len(args)
+    for a in args:
+        b = a.encode() if isinstance(a, str) else a
+        data += b"$%d\r\n%s\r\n" % (len(b), b)
+    sock.sendall(data)
+    return read(sock)
+
+
+def read(sock):
+    line = readline(sock)
+    t, rest = line[:1], line[1:]
+    if t in (b"+", b"-"):
+        return rest.decode()
+    if t == b":":
+        return int(rest)
+    if t == b"$":
+        n = int(rest)
+        if n == -1:
+            return None
+        buf = b""
+        while len(buf) < n + 2:
+            buf += sock.recv(n + 2 - len(buf))
+        return buf[:n].decode()
+    if t == b"*":
+        n = int(rest)
+        return None if n == -1 else [read(sock) for _ in range(n)]
+    return line.decode(errors="replace")
+
+
+def readline(sock):
+    buf = b""
+    while not buf.endswith(b"\r\n"):
+        c = sock.recv(1)
+        if not c:
+            return buf
+        buf += c
+    return buf[:-2]
+
+
+class Redis:
+    def __enter__(self):
+        self.s = socket.create_connection((REDIS[0], REDIS[1]), timeout=5)
+        resp(self.s, "AUTH", REDIS[2])
+        return self
+
+    def __exit__(self, *a):
+        self.s.close()
+
+    def __call__(self, *args):
+        return resp(self.s, *args)
+
+
+def login(username, password):
+    return S.post(BASE + "/store/passport/login",
+                  headers={"Content-Type": "application/json"},
+                  data=json.dumps({"username": username, "password": password}),
+                  timeout=30).json()
+
+
+def api(token):
+    headers = {"Access-Token": token} if token else {}
+    return S.get(BASE + "/store/goods/list", headers=headers, timeout=30).json()
+
+
+def main():
+    with Redis() as r:
+        print("== 1. 登录 ==")
+        res = login("redistest_admin", "123456")
+        check("登录成功", res.get("status") == 200 and res.get("message") == "登录成功", json.dumps(res, ensure_ascii=False))
+        token = (res.get("data") or {}).get("token")
+        check("返回 token", isinstance(token, str) and len(token) == 32, str(token))
+        if not token:
+            return
+
+        key = PREFIX + token
+        print("== 2. Redis 落库 ==")
+        raw = r("GET", key)
+        check("key 存在", raw is not None, "key=%s" % key)
+        ttl = r("TTL", key)
+        check("TTL 为 7 天(604800,允许 5 秒误差)", isinstance(ttl, int) and 604795 <= ttl <= 604800, "ttl=%s" % ttl)
+        try:
+            val = json.loads(raw) if raw else {}
+            # RedisConfig 的 value 序列化器是 Jackson2JsonRedisSerializer,
+            # 写入 String 时会再包一层 JSON 编码(存储为 "{\"user\":...}"),
+            # 这里解两层,兼容纯 JSON 与双层编码两种存法。
+            if isinstance(val, str):
+                val = json.loads(val)
+        except Exception:
+            val = {}
+        check("value.user 有 store_user_id", isinstance(val.get("user"), dict) and "store_user_id" in val["user"],
+              json.dumps(val, ensure_ascii=False)[:160])
+        check("value.store_id 正确", val.get("store_id") == 10001, "store_id=%s" % val.get("store_id"))
+        check("value.is_login 为 true", val.get("is_login") is True, "is_login=%r" % val.get("is_login"))
+
+        print("== 3. 带 token 访问业务接口 ==")
+        ok = api(token)
+        check("/store/goods/list 返回 200", ok.get("status") == 200, json.dumps(ok, ensure_ascii=False)[:160])
+
+        print("== 4. 无 token 应 401 ==")
+        no = api(None)
+        check("无 token 返回 401", no.get("status") == 401 and no.get("message") == "请先登录后再访问",
+              json.dumps(no, ensure_ascii=False)[:160])
+
+        print("== 5. 登出 ==")
+        out = S.post(BASE + "/store/passport/logout", headers={"Access-Token": token}, timeout=30).json()
+        check("登出返回 200/操作成功", out.get("status") == 200 and out.get("message") == "操作成功",
+              json.dumps(out, ensure_ascii=False)[:160])
+
+        print("== 6. Redis 已清除 ==")
+        check("key 已删除", r("GET", key) is None, "key=%s" % key)
+        check("TTL 为 -2", r("TTL", key) == -2, "ttl=%s" % r("TTL", key))
+
+        print("== 7. 登出后 token 失效 ==")
+        after = api(token)
+        check("旧 token 返回 401", after.get("status") == 401, json.dumps(after, ensure_ascii=False)[:160])
+
+    print("\n通过 %d / 失败 %d" % (passed, failed))
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 16 - 6
yoshop-java/README.md

@@ -41,21 +41,31 @@ cn.yoshop
 
 ## 认证(已实现)
 
+登录态存 **Redis**(不再兼容 PHP 的 File 缓存):
+
 ```
 请求头 Access-Token
-  → md5(token) → {runtime}/cache/{前2位}/{后30位}.php
-  → 文件头 32 字节("<?php
-//" + 12位过期时间戳 + "
- exit();?>
-")+ PHP serialize 数据
+  → key = yoshop:store:token:{token}
+  → value = {"user":{...},"store_id":10001,"is_login":true}(JSON)
+  → TTL 604800 秒(7 天,可通过 yoshop.auth.token-ttl 调整)
 ```
 
 `StoreAuthInterceptor` 校验登录态,白名单 `passport/login`、`passport/logout`、`map/transfer`,
 失败返回 `{"status":401,"message":"请先登录后再访问","data":{"isPrompt":true}}`。
 
-配置 `yoshop.auth.mode`:`"off"` 开发/双跑放行;`"file"` 真实校验(需与 PHP 同机或目录可见)。
+配置 `yoshop.auth.mode`:`"off"` 开发/联调放行;`"redis"` 真实校验。
 **注意必须带引号**,裸 `off` 会被 YAML 解析成布尔 false。
 
+相关配置:
+
+```yaml
+yoshop:
+  auth:
+    mode: "redis"
+    token-prefix: "yoshop:store:token:"
+    token-ttl: 604800
+```
+
 ## 列表 / 详情出参分离
 
 详情多出的 `skuList`/`video`/`videoCover`/`categoryIds`/`serviceIds`/`isSpecLocked`/`specList`/`content`

+ 132 - 0
yoshop-java/src/main/java/cn/yoshop/common/auth/RedisTokenStore.java

@@ -0,0 +1,132 @@
+package cn.yoshop.common.auth;
+
+import cn.yoshop.utils.RedisUtils;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 商家后台登录态存储(Redis)。
+ *
+ * <p>不再兼容 PHP 的 File 缓存格式,key 与 value 均由 Java 端自定义:
+ * <ul>
+ *   <li>key = {@code ${yoshop.auth.token-prefix} + token}</li>
+ *   <li>value = JSON {@code {"user":{...},"store_id":10001,"is_login":true}}</li>
+ *   <li>有效期默认 7 天(对齐原 PHP 端 {@code Cache::set($token, $value, 86400 * 7)})</li>
+ * </ul>
+ *
+ * <p>字段名沿用 PHP 的 snake_case({@code store_id} / {@code is_login}),
+ * 这样 {@link LoginInfo#fromCacheValue(Map)} 可直接复用,无需再写一套解析。
+ */
+@Component
+public class RedisTokenStore {
+
+    private static final Logger log = LoggerFactory.getLogger(RedisTokenStore.class);
+
+    private final RedisUtils redis;
+
+    /** 登录态 JSON 的读写用自己的 mapper,避免受全局 SNAKE_CASE 配置影响 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Value("${yoshop.auth.token-prefix:yoshop:store:token:}")
+    private String prefix;
+
+    /** 默认有效期(秒),可由 yoshop.auth.token-ttl 覆盖 */
+    @Value("${yoshop.auth.token-ttl:604800}")
+    private long defaultTtlSeconds;
+
+    public RedisTokenStore(RedisUtils redis) {
+        this.redis = redis;
+    }
+
+    /**
+     * 读取登录态。token 为空、键不存在或内容损坏时返回 null(按未登录处理)。
+     * Redis 不可用同样返回 null,避免异常把整个后台打挂。
+     */
+    public LoginInfo getLoginInfo(String token) {
+        if (token == null || token.isBlank()) {
+            return null;
+        }
+        try {
+            // RedisUtils.get 返回 Object,键不存在时为 null,必须先判空再 toString
+            Object cached = redis.get(key(token));
+            if (cached == null) {
+                return null;
+            }
+            String json = cached.toString();
+            Map<String, Object> value = objectMapper.readValue(
+                    json, new TypeReference<LinkedHashMap<String, Object>>() {
+                    });
+            return LoginInfo.fromCacheValue(value);
+        } catch (Exception e) {
+            log.warn("读取登录态失败 token={} : {}", token, e.getMessage());
+            return null;
+        }
+    }
+
+    /** 写入登录态(有效期取配置 yoshop.auth.token-ttl,默认 7 天) */
+    public void save(String token, LoginInfo info) {
+        save(token, info, defaultTtlSeconds);
+    }
+
+    /**
+     * 写入登录态。
+     *
+     * @param ttlSeconds 有效期秒数,<=0 表示不设过期
+     */
+    public void save(String token, LoginInfo info, long ttlSeconds) {
+        Map<String, Object> value = new LinkedHashMap<>();
+        value.put("user", info.getUser());
+        value.put("store_id", info.getStoreId());
+        value.put("is_login", info.isLogin());
+        String json;
+        try {
+            json = objectMapper.writeValueAsString(value);
+        } catch (Exception e) {
+            throw new IllegalStateException("登录态序列化失败: " + e.getMessage(), e);
+        }
+        if (ttlSeconds > 0) {
+            redis.set(key(token), json, ttlSeconds);
+        } else {
+            redis.set(key(token), json);
+        }
+    }
+
+    /** 删除登录态(登出)。删除失败只记日志,不影响登出流程 */
+    public void delete(String token) {
+        if (token == null || token.isBlank()) {
+            return;
+        }
+        try {
+            redis.delete(key(token));
+        } catch (Exception e) {
+            log.warn("清除登录态失败 token={} : {}", token, e.getMessage());
+        }
+    }
+
+    /**
+     * 剩余有效期(秒)。用于资料变更时刷新缓存:key 不存在返回 -2,无过期时间返回 -1。
+     */
+    public long remainingTtlSeconds(String token) {
+        if (token == null || token.isBlank()) {
+            return -2L;
+        }
+        try {
+            Long ttl = redis.ttl(key(token));
+            return ttl == null ? -2L : ttl;
+        } catch (Exception e) {
+            log.warn("读取登录态有效期失败 token={} : {}", token, e.getMessage());
+            return -2L;
+        }
+    }
+
+    private String key(String token) {
+        return prefix + token;
+    }
+}

+ 5 - 4
yoshop-java/src/main/java/cn/yoshop/common/auth/StoreAuthInterceptor.java

@@ -22,6 +22,7 @@ import java.util.Set;
  *   <li>白名单 routeUri:passport/login、passport/logout、map/transfer</li>
  *   <li>未登录响应:{"status":401,"message":"请先登录后再访问","data":{"isPrompt":true}},HTTP 恒 200</li>
  *   <li>登录信息放入 request attribute "loginInfo",供控制器使用</li>
+ *   <li>登录态存 Redis(见 {@link RedisTokenStore}),不再读取 PHP 的 File 缓存</li>
  * </ul>
  *
  * <p>注意:PHP 端 initialize() 还有 checkPrivilege()(按角色校验 API 权限,查 store_api 表),
@@ -40,16 +41,16 @@ public class StoreAuthInterceptor implements HandlerInterceptor {
     /** 未登录响应 data,对齐 PHP ExceptionHandle::output() 的 array_merge(['isPrompt' => true], ...) */
     private static final Map<String, Object> ERROR_DATA = Map.of("isPrompt", true);
 
-    private final ThinkFileCacheTokenStore tokenStore;
+    private final RedisTokenStore tokenStore;
     private final ObjectMapper objectMapper;
 
     /**
-     * 认证模式:off = 开发/双跑(放行所有请求);file = 真实校验 PHP 缓存。
+     * 认证模式:off = 开发/联调(放行所有请求);redis = 真实校验(登录态存 Redis)。
      */
-    @Value("${yoshop.auth.mode:off}")
+    @Value("${yoshop.auth.mode:redis}")
     private String mode;
 
-    public StoreAuthInterceptor(ThinkFileCacheTokenStore tokenStore, ObjectMapper objectMapper) {
+    public StoreAuthInterceptor(RedisTokenStore tokenStore, ObjectMapper objectMapper) {
         this.tokenStore = tokenStore;
         this.objectMapper = objectMapper;
     }

+ 0 - 130
yoshop-java/src/main/java/cn/yoshop/common/auth/ThinkFileCacheTokenStore.java

@@ -1,130 +0,0 @@
-package cn.yoshop.common.auth;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-
-import java.io.File;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.security.MessageDigest;
-import java.util.Map;
-
-/**
- * 读取 ThinkPHP File 缓存驱动的登录态,复现
- * vendor/topthink/framework/src/think/cache/driver/File.php 的存储规则:
- *
- * <ul>
- *   <li>文件名 = md5(cacheKey),前 2 位为子目录:{path}/{md5[0:2]}/{md5[2:]}.php</li>
- *   <li>文件内容 = "&lt;?php\n//" (8字节) + sprintf('%012d', 过期绝对时间戳) (12字节)
- *       + "\n exit();?&gt;\n" (12字节) + PHP serialize() 的缓存值,共 32 字节头</li>
- *   <li>过期判断:expire != 0 且 time() - expire &gt; filemtime</li>
- * </ul>
- *
- * <p>前提:Java 与 PHP 部署在同一台服务器(或 runtime/cache 目录可见)。
- * PHP 端写入见 app/common/service/store/User::login(),有效期 7 天。
- */
-@Component
-public class ThinkFileCacheTokenStore {
-
-    private static final int HEADER_LEN = 32;
-
-    /** PHP runtime/cache 目录绝对路径 */
-    @Value("${yoshop.auth.runtime-cache-path:/www/wwwroot/shop/runtime/cache}")
-    private String cachePath;
-
-    /** 按 token 读取登录信息;无效/过期/不存在返回 null */
-    public LoginInfo getLoginInfo(String token) {
-        if (token == null || token.isBlank()) {
-            return null;
-        }
-        try {
-            File file = cacheFile(token);
-            if (!file.isFile()) {
-                return null;
-            }
-            byte[] content = Files.readAllBytes(file.toPath());
-            if (content.length <= HEADER_LEN) {
-                return null;
-            }
-            long expire = parseDigits(content, 8, 20);
-            if (expire != 0) {
-                long fileMtimeSec = file.lastModified() / 1000;
-                if (System.currentTimeMillis() / 1000 - expire > fileMtimeSec) {
-                    return null; // 已过期(PHP 会顺手删文件,Java 只判空即可)
-                }
-            }
-            byte[] serialized = new byte[content.length - HEADER_LEN];
-            System.arraycopy(content, HEADER_LEN, serialized, 0, serialized.length);
-            Object parsed = PhpUnserializer.parse(serialized);
-            if (!(parsed instanceof Map)) {
-                return null;
-            }
-            return LoginInfo.fromCacheValue((Map<String, Object>) parsed);
-        } catch (Exception e) {
-            // 缓存文件损坏或格式变化时按未登录处理,不阻断业务
-            return null;
-        }
-    }
-
-    private File cacheFile(String token) throws Exception {
-        MessageDigest md = MessageDigest.getInstance("MD5");
-        byte[] digest = md.digest(token.getBytes(StandardCharsets.UTF_8));
-        StringBuilder hex = new StringBuilder(32);
-        for (byte b : digest) {
-            hex.append(Character.forDigit((b >> 4) & 0xF, 16));
-            hex.append(Character.forDigit(b & 0xF, 16));
-        }
-        String name = hex.toString();
-        return new File(cachePath, name.substring(0, 2) + File.separator + name.substring(2) + ".php");
-    }
-
-    /**
-     * 按 ThinkPHP File 驱动规则写入登录态(Java 端自主登录时使用)。
-     * 写入格式与 PHP 完全一致,PHP 侧也能读到,便于双跑期共用登录。
-     *
-     * @param ttlSeconds 有效期秒数,<=0 表示永不过期
-     */
-    public void write(String token, LoginInfo info, long ttlSeconds) throws Exception {
-        File file = cacheFile(token);
-        File dir = file.getParentFile();
-        if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
-            throw new IllegalStateException("无法创建缓存目录: " + dir);
-        }
-        long expire = ttlSeconds > 0 ? System.currentTimeMillis() / 1000 + ttlSeconds : 0L;
-        // 32 字节固定头:"<?php\n" + "//" + 12位过期时间戳 + "\n exit();?>\n"
-        String header = "<?php\n//" + String.format("%012d", expire) + "\n exit();?>\n";
-        byte[] head = header.getBytes(StandardCharsets.UTF_8);
-        Map<String, Object> value = new java.util.LinkedHashMap<>();
-        value.put("user", info.getUser());
-        value.put("store_id", info.getStoreId());
-        value.put("is_login", info.isLogin());
-        byte[] body = PhpSerializer.serialize(value);
-        byte[] content = new byte[head.length + body.length];
-        System.arraycopy(head, 0, content, 0, head.length);
-        System.arraycopy(body, 0, content, head.length, body.length);
-        Files.write(file.toPath(), content);
-    }
-
-    /** 删除登录态(登出) */
-    public void delete(String token) {
-        if (token == null || token.isBlank()) {
-            return;
-        }
-        try {
-            Files.deleteIfExists(cacheFile(token).toPath());
-        } catch (Exception ignored) {
-            // 删除失败不阻断登出流程
-        }
-    }
-
-    private long parseDigits(byte[] content, int from, int to) {
-        long v = 0;
-        for (int i = from; i < to; i++) {
-            char c = (char) (content[i] & 0xFF);
-            if (c >= '0' && c <= '9') {
-                v = v * 10 + (c - '0');
-            }
-        }
-        return v;
-    }
-}

+ 90 - 0
yoshop-java/src/main/java/cn/yoshop/config/RedisConfig.java

@@ -0,0 +1,90 @@
+package cn.yoshop.config;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.cache.RedisCacheWriter;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+import java.net.UnknownHostException;
+import java.time.Duration;
+
+/**
+ * Description:
+ *
+ * @author tanlie
+ * Date: 2024/1/9 9:07
+ */
+@EnableCaching
+@Configuration
+public class RedisConfig {
+    /**
+     * 缓存管理器
+     *
+     * @param factory
+     * @return
+     */
+    @Bean
+    @SuppressWarnings("all")
+    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
+        ObjectMapper om = new ObjectMapper();
+        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
+        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
+        // 解决查询缓存转换异常的问题
+        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
+        jackson2JsonRedisSerializer.setObjectMapper(om);
+        // 配置序列化(解决乱码的问题)
+        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
+                .entryTtl(Duration.ofMillis(-1))
+                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
+                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
+                .disableCachingNullValues();
+        RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(factory);
+
+        return new RedisConfigCacheManager(cacheWriter, config);
+    }
+
+    //重写template配置累
+    @Bean
+    @SuppressWarnings("all")
+    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) throws UnknownHostException {
+
+        RedisTemplate<String, Object> template = new RedisTemplate();
+        template.setConnectionFactory(factory);
+        //json序列化
+        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
+        ObjectMapper om = new ObjectMapper();
+        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
+        jackson2JsonRedisSerializer.setObjectMapper(om);
+        //String 序列化
+        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
+        //key 采用string序列化
+        template.setKeySerializer(stringRedisSerializer);
+        template.setHashKeySerializer(stringRedisSerializer);  //hash key也采用string
+        //value 采用json
+        template.setValueSerializer(jackson2JsonRedisSerializer);
+        template.setHashValueSerializer(jackson2JsonRedisSerializer);
+
+        template.afterPropertiesSet();
+        return template;
+    }
+
+    @Bean
+    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
+        return new StringRedisTemplate(factory);
+    }
+
+}

+ 53 - 0
yoshop-java/src/main/java/cn/yoshop/config/RedisConfigCacheManager.java

@@ -0,0 +1,53 @@
+package cn.yoshop.config;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.data.redis.cache.*;
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
+import org.springframework.data.redis.serializer.RedisSerializationContext;
+
+import java.time.Duration;
+
+/**
+ * Description:
+ *
+ * @author tanlie
+ * Date: 2024/9/5 11:01
+ */
+public class RedisConfigCacheManager extends RedisCacheManager {
+
+    private static final CacheKeyPrefix DEFAULT_CACHE_KEY_PREFIX = cacheName -> cacheName + ":";
+
+    public RedisConfigCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
+        super(cacheWriter, defaultCacheConfiguration);
+    }
+
+    private static final RedisSerializationContext.SerializationPair<Object> DEFAULT_PAIR = RedisSerializationContext.SerializationPair
+            .fromSerializer(new GenericJackson2JsonRedisSerializer());
+
+    /**
+     * @param name
+     * @param cacheConfig
+     * @return
+     */
+    @Override
+    protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
+        final int lastIndexOf = StringUtils.lastIndexOf(name, '#');
+        if (lastIndexOf > -1) {
+            final String ttl = StringUtils.substring(name, lastIndexOf + 1);
+            final Duration duration = Duration.ofSeconds(Long.parseLong(ttl));
+            cacheConfig = cacheConfig.entryTtl(duration);
+            //修改缓存key和value值的序列化方式
+            cacheConfig = cacheConfig.computePrefixWith(DEFAULT_CACHE_KEY_PREFIX)
+                    .serializeValuesWith(DEFAULT_PAIR);
+            final String cacheName = StringUtils.substring(name, 0, lastIndexOf);
+            return super.createRedisCache(cacheName, cacheConfig);
+        } else {
+            //修改缓存key和value值的序列化方式
+            cacheConfig = cacheConfig.computePrefixWith(DEFAULT_CACHE_KEY_PREFIX)
+                    .serializeValuesWith(DEFAULT_PAIR);
+            return super.createRedisCache(name, cacheConfig);
+        }
+    }
+
+
+}

+ 13 - 10
yoshop-java/src/main/java/cn/yoshop/store/service/StoreUserService.java

@@ -3,7 +3,7 @@ package cn.yoshop.store.service;
 import cn.hutool.core.util.IdUtil;
 import cn.hutool.crypto.digest.DigestUtil;
 import cn.yoshop.common.auth.LoginInfo;
-import cn.yoshop.common.auth.ThinkFileCacheTokenStore;
+import cn.yoshop.common.auth.RedisTokenStore;
 import cn.yoshop.common.entity.Store;
 import cn.yoshop.common.entity.StoreUser;
 import cn.yoshop.common.exception.BizException;
@@ -25,16 +25,13 @@ import java.util.Map;
 @Service
 public class StoreUserService {
 
-    /** 登录态有效期 7 天,与 PHP Cache::set(..., 86400 * 7) 一致 */
-    private static final long TOKEN_TTL_SECONDS = 86400L * 7;
-
     private final StoreUserMapper storeUserMapper;
     private final StoreMapper storeMapper;
-    private final ThinkFileCacheTokenStore tokenStore;
+    private final RedisTokenStore tokenStore;
 
     public StoreUserService(StoreUserMapper storeUserMapper,
                             StoreMapper storeMapper,
-                            ThinkFileCacheTokenStore tokenStore) {
+                            RedisTokenStore tokenStore) {
         this.storeUserMapper = storeUserMapper;
         this.storeMapper = storeMapper;
         this.tokenStore = tokenStore;
@@ -74,7 +71,8 @@ public class StoreUserService {
         info.setStoreId(user.getStoreId() == null ? 0L : user.getStoreId());
         info.setLogin(true);
         try {
-            tokenStore.write(token, info, TOKEN_TTL_SECONDS);
+            // 有效期取 yoshop.auth.token-ttl,默认 7 天
+            tokenStore.save(token, info);
         } catch (Exception e) {
             throw new BizException("登录失败, 登录态写入失败: " + e.getMessage());
         }
@@ -85,14 +83,14 @@ public class StoreUserService {
         return data;
     }
 
-    /** 登出:删除缓存中的登录态 */
+    /** 登出:删除 Redis 中的登录态,删除后同一 token 再访问业务接口应返回 401 */
     public void logout(String token) {
         tokenStore.delete(token);
     }
 
     /**
      * 资料变更后刷新登录态缓存(对齐 PHP StoreUserService::update($this->toArray()))。
-     * 登录态不存在时静默跳过,不影响主流程。
+     * 登录态不存在时静默跳过;存在时沿用剩余有效期,不因改资料延长登录时长。
      */
     public void refreshLoginCache(String token, StoreUser user) {
         if (token == null || token.isBlank() || user == null) {
@@ -103,8 +101,13 @@ public class StoreUserService {
             if (info == null) {
                 return;
             }
+            long ttl = tokenStore.remainingTtlSeconds(token);
             info.setUser(toCacheUser(user));
-            tokenStore.write(token, info, TOKEN_TTL_SECONDS);
+            if (ttl > 0) {
+                tokenStore.save(token, info, ttl);
+            } else {
+                tokenStore.save(token, info);
+            }
         } catch (Exception ignored) {
             // 缓存刷新失败不阻断资料更新
         }

+ 173 - 0
yoshop-java/src/main/java/cn/yoshop/utils/RedisUtils.java

@@ -0,0 +1,173 @@
+package cn.yoshop.utils;
+
+import jakarta.annotation.Resource;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Description:
+ *
+ * @author tanlie
+ * Date: 2024/1/9 9:08
+ */
+@Component
+public class RedisUtils {
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    /**
+     * 分布式锁过期时间
+     */
+    private final long EXPIRE_TIME = 5 * 10000;
+
+    public Object get(String key) {
+        return redisTemplate.opsForValue().get(key);
+    }
+
+    public void set(String key, Object obj) {
+        redisTemplate.opsForValue().set(key, obj);
+    }
+
+    public void set(String key, Object obj, long expire) {
+        redisTemplate.opsForValue().set(key, obj, expire, TimeUnit.SECONDS);
+    }
+
+    public Boolean delete(String key) {
+        return redisTemplate.delete(key);
+    }
+
+
+    public Long delete(List<String> keys) {
+        return redisTemplate.delete(keys);
+    }
+
+    public Long delete(Set<String> keys) {
+        return redisTemplate.delete(keys);
+    }
+
+    public Long lPush(String key, Object... obj) {
+        return redisTemplate.opsForList().leftPush(key, obj);
+    }
+
+    public Long rPush(String key, Object... obj) {
+        return redisTemplate.opsForList().rightPush(key, obj);
+    }
+
+    public Object lPop(String key) {
+        return redisTemplate.opsForList().leftPop(key);
+    }
+
+    public Object rPop(String key) {
+        return redisTemplate.opsForList().rightPop(key);
+    }
+
+    public Boolean exists(String key) {
+        return redisTemplate.hasKey(key);
+    }
+
+    public Long ttl(String key) {
+        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
+    }
+
+    public void expire(String key, Long timeout) {
+        redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
+    }
+
+    public Map<Object, Object> hGetAll(String key) {
+        return redisTemplate.opsForHash().entries(key);
+    }
+
+    public void hPut(String key, Object field, Object obj) {
+        this.redisTemplate.opsForHash().put(key, field, obj);
+    }
+
+    public Object hGet(String key, String field) {
+        return redisTemplate.opsForHash().get(key, field);
+    }
+
+    public void hSet(String key, String field, Object value) {
+        redisTemplate.opsForHash().put(key, field, value);
+    }
+
+    public Long hDel(String key, Object... fields) {
+        return redisTemplate.opsForHash().delete(key, fields);
+    }
+
+    public Set<String> keys(String pattern) {
+        return redisTemplate.keys(pattern);
+    }
+
+    /**
+     * redisTemplate删除迷糊匹配的key的缓存
+     */
+    public void deleteByPrefix(String prefix) {
+        Set<String> keys = redisTemplate.keys(prefix);
+        if (keys != null && !keys.isEmpty()) {
+            redisTemplate.delete(keys);
+        }
+    }
+
+    public boolean incr(String key, long delta) {
+        if (delta < 0) {
+            throw new RuntimeException("递增因子必须大于0");
+        }
+        try {
+            redisTemplate.opsForValue().increment(key, delta);
+            return true;
+        } catch (Exception ex) {
+            return false;
+        }
+    }
+
+    /**
+     * Redis加锁的操作
+     *
+     * @param key
+     * @param value
+     * @return
+     */
+    public Boolean tryLock(String key, String value) {
+        if (redisTemplate.opsForValue().setIfAbsent(key, value, EXPIRE_TIME, TimeUnit.SECONDS)) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Redis加锁的操作
+     *
+     * @param key
+     * @param value
+     * @return
+     */
+    public Boolean tryLock(String key, String value, long expireTime) {
+        if (redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.SECONDS)) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Redis解锁的操作
+     *
+     * @param key
+     * @param value
+     */
+    public void unlock(String key, String value) {
+        String currentValue = (String) redisTemplate.opsForValue().get(key);
+        try {
+            if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
+                redisTemplate.opsForValue().getOperations().delete(key);
+            }
+        } catch (Exception e) {
+        }
+    }
+}

+ 5 - 4
yoshop-java/src/main/resources/application.yml

@@ -52,7 +52,8 @@ yoshop:
   # 订单导出文件目录,/downloads/** 映射到该目录(file_path 形如 downloads/{storeId}/order-x.xlsx)
   export-path: runtime/downloads
   auth:
-    # off = 开发/双跑模式(放行);file = 真实校验(读取 PHP File 缓存,需与 PHP 同机或目录可见)
-    mode: "off"
-    # PHP 部署机的 runtime/cache 绝对路径(ThinkPHP File 缓存驱动目录)
-    runtime-cache-path: /www/wwwroot/shop/runtime/cache
+    # off = 开发/联调模式(放行所有请求);redis = 真实校验(登录态存 Redis)
+    mode: "redis"
+    # 登录态在 Redis 中的 key 前缀与有效期(秒),默认 7 天
+    token-prefix: "yoshop:store:token:"
+    token-ttl: 604800

+ 2 - 2
接口实现进度-第8批.md

@@ -128,5 +128,5 @@
 - `F:/shop/shop/.env` 已按原字节还原(备份 `/tmp/yoshop_env_backup.txt`,即 localhost/test)。
 - 本地 PHP 站点(127.0.0.1:8899,PHP 8.2 便携包)已关闭;PHP 8.2 目录保留在
   `C:/Users/Administrator/AppData/Local/Temp/php82/`,需要再跑双端对比时可直接复用。
-- Java 服务(18080)启动命令保持不变:
-  `mvn -q -o -DskipTests spring-boot:run -Dspring-boot.run.arguments="--server.port=18080 --yoshop.auth.mode=file --yoshop.auth.runtime-cache-path=<...>/runtime/cache"`。
+- Java 服务(18080)启动命令:`mvn -q -o -DskipTests spring-boot:run -Dspring-boot.run.arguments="--server.port=18080"`
+  (登录态已改为存 Redis,`yoshop.auth.mode` 默认 `redis`)。

+ 5 - 3
联调说明.md

@@ -6,11 +6,13 @@
 
 ```bash
 cd F:\shop\java-rewrite\yoshop-java
-mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=18080 --yoshop.auth.mode=file --yoshop.auth.runtime-cache-path=F:/shop/java-rewrite/runtime/cache"
+mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=18080"
 ```
 
-- `yoshop.auth.mode=file`:开启真实登录校验(读 ThinkPHP File 缓存)
-- `runtime-cache-path`:Java 自己写/读登录态的目录;登录成功后 token 文件写在这里
+- `yoshop.auth.mode=redis`(默认,见 application.yml):真实登录校验,登录态存 Redis
+- `--yoshop.auth.mode=off`:临时跳过登录校验(本地调试前端时用)
+- Redis 连接复用 `spring.data.redis`,key 前缀 `yoshop:store:token:`,TTL 7 天
+- 注意:本机环境变量 `SERVER__PORT` 会覆盖配置端口,启动时务必显式带 `--server.port=18080`
 
 **前端(Vue)** —— 端口 8120