2 Komitmen 385e16ff42 ... d33b8fac8f

Pembuat SHA1 Pesan Tanggal
  tanlie d33b8fac8f 商家后台登录态改用 Redis 存储,不再兼容 PHP File 缓存 10 jam lalu
  tanlie cb14ba17cd API 清单按模块拆分为四个 sheet 10 jam lalu

TEMPAT SAMPAH
API清单.xlsx


TEMPAT SAMPAH
backup/API清单-原始.xlsx


+ 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()

+ 79 - 0
tools/split_api_list.py

@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+"""把 API清单.xlsx 按 module 拆成四个 sheet(admin / store / api / timer)。
+
+- 保留原 9 列与行序;
+- 表头加粗、冻结首行、自动筛选、列宽与自动换行;
+- 原文件先备份到 backup/API清单-原始.xlsx。
+"""
+import shutil
+from pathlib import Path
+
+import openpyxl
+from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
+from openpyxl.utils import get_column_letter
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "API清单.xlsx"
+BACKUP = ROOT / "backup" / "API清单-原始.xlsx"
+
+SHEETS = [("admin", "管理端"), ("store", "商家端"), ("api", "客户端"), ("timer", "定时任务")]
+WIDTHS = [10, 18, 18, 16, 24, 44, 18, 26, 8]
+
+HEAD_FILL = PatternFill("solid", fgColor="1F3864")
+HEAD_FONT = Font(name="微软雅黑", size=10, bold=True, color="FFFFFF")
+BODY_FONT = Font(name="微软雅黑", size=10)
+THIN = Side(style="thin", color="D9D9D9")
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
+
+
+def main():
+    src_wb = openpyxl.load_workbook(SRC)
+    src_ws = src_wb["API清单"]
+    rows = list(src_ws.iter_rows(values_only=True))
+    header, data = rows[0], [r for r in rows[1:] if any(v is not None for v in r)]
+
+    BACKUP.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copy2(SRC, BACKUP)
+
+    wb = openpyxl.Workbook()
+    wb.remove(wb.active)
+
+    for module, label in SHEETS:
+        subset = [r for r in data if r[0] == module]
+        ws = wb.create_sheet("%s-%s" % (module, label))
+        ws.append(header)
+        for r in subset:
+            ws.append(list(r))
+
+        for c in range(1, len(header) + 1):
+            cell = ws.cell(row=1, column=c)
+            cell.fill = HEAD_FILL
+            cell.font = HEAD_FONT
+            cell.alignment = Alignment(horizontal="center", vertical="center")
+            cell.border = BORDER
+            ws.column_dimensions[get_column_letter(c)].width = WIDTHS[c - 1]
+        ws.row_dimensions[1].height = 24
+
+        for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(header)):
+            for cell in row:
+                cell.font = BODY_FONT
+                cell.border = BORDER
+                col = cell.column
+                if col in (7, 8):
+                    cell.alignment = Alignment(vertical="top", wrap_text=True)
+                elif col == 9:
+                    cell.alignment = Alignment(horizontal="center", vertical="top")
+                else:
+                    cell.alignment = Alignment(vertical="center")
+
+        ws.freeze_panes = "A2"
+        ws.auto_filter.ref = "A1:%s%d" % (get_column_letter(len(header)), max(ws.max_row, 1))
+        print("%-14s %d 条" % (ws.title, len(subset)))
+
+    wb.save(SRC)
+    print("已保存:", SRC)
+    print("原文件备份:", BACKUP)
+
+
+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