浏览代码

新增平台后台 admin 模块接口(23 个),store / admin 两套登录态彻底分开

接口范围(app/admin,24 个清单项落地 23 个,controller/initialize 为 PHP 基类钩子不实现):
- 认证:passport/login、passport/logout
- 商城管理:store/index、add(免费版固定拒绝)、recycle、recovery(移入回收站)、edit、move(移出回收站)
- 平台管理员:admin.user/detail、admin.user/renew
- 权限点:store.api/index(树)、add、edit(防环)、delete(子级保护 + 清菜单关联)
- 后台菜单:store.menu/index(树 + apiIds)、info、add、edit(防环)、set_apis(先删后插)、delete
- 运维:setting.cache/clear、setting.science/info、setting.timer/test
实现:
- 新增包 cn.yoshop.admin:7 个 service + 8 个 controller + AdminMenu 实体(平台侧字段集与商家端 StoreMenu 不同)
- 公共层新增 AdminUser / StoreApi / SystemProcess 实体与三个 Mapper
- 新增 AdminTokenStore(Redis,yoshop:admin:token:{token},值=管理员 JSON 不含 password,TTL 7 天)
  与 AdminAuthInterceptor(白名单仅 passport/login,未登录 401)
- 修复 WebMvcConfig:此前 /admin/** 误挂商家端 StoreAuthInterceptor,现按前缀拆成两套
- application.yml 增加 yoshop.auth.admin-token-prefix / admin-token-ttl、yoshop.temp-cache-paths
差异(有意为之):
- admin.user/detail 读登录态缓存不回查数据库;renew 空密码视为不修改(PHP 会哈希空串锁死账号)
- science/info 改 Java 语义检测(JDK/容器/内存/编码/时区/DB/Redis/目录可写),三段结构保持不变
- cache/clear 清 yoshop:cache: 前缀,isForce 清 yoshop:*;temp 只删配置白名单目录并校验在工作目录内
验证:
- 新增 tools/test_admin_api.py,45 项断言全通过(登录态结构、越权 401、回收站往返、防环、子级保护、
  set_apis 绑定、cache 不清登录态、timer 与 system_process 一致、登出失效)
- 测试数据已还原:临时权限点/菜单删除、store 的 remark/sort/is_recycle 还原、admin 口令哈希还原
文档:
- 新增 接口实现进度-第9批-admin.md;联调说明.md 增「六、平台后台(admin)联调」
tanlie 10 小时之前
父节点
当前提交
fcebb5d4c5
共有 30 个文件被更改,包括 2399 次插入 和 4 次删除
  1. 389 0
      tools/test_admin_api.py
  2. 33 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminCacheController.java
  3. 41 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminPassportController.java
  4. 26 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminScienceController.java
  5. 59 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreApiController.java
  6. 80 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreController.java
  7. 80 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreMenuController.java
  8. 25 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminTimerController.java
  9. 42 0
      yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminUserController.java
  10. 72 0
      yoshop-java/src/main/java/cn/yoshop/admin/entity/AdminMenu.java
  11. 12 0
      yoshop-java/src/main/java/cn/yoshop/admin/mapper/AdminMenuMapper.java
  12. 139 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminApiService.java
  13. 116 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminCacheService.java
  14. 199 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminMenuService.java
  15. 179 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminScienceService.java
  16. 206 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminStoreService.java
  17. 43 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminTimerService.java
  18. 145 0
      yoshop-java/src/main/java/cn/yoshop/admin/service/AdminUserService.java
  19. 98 0
      yoshop-java/src/main/java/cn/yoshop/common/auth/AdminAuthInterceptor.java
  20. 148 0
      yoshop-java/src/main/java/cn/yoshop/common/auth/AdminTokenStore.java
  21. 45 0
      yoshop-java/src/main/java/cn/yoshop/common/entity/AdminUser.java
  22. 57 0
      yoshop-java/src/main/java/cn/yoshop/common/entity/StoreApi.java
  23. 25 0
      yoshop-java/src/main/java/cn/yoshop/common/entity/SystemProcess.java
  24. 9 0
      yoshop-java/src/main/java/cn/yoshop/common/mapper/AdminUserMapper.java
  25. 9 0
      yoshop-java/src/main/java/cn/yoshop/common/mapper/StoreApiMapper.java
  26. 9 0
      yoshop-java/src/main/java/cn/yoshop/common/mapper/SystemProcessMapper.java
  27. 9 3
      yoshop-java/src/main/java/cn/yoshop/config/WebMvcConfig.java
  28. 4 1
      yoshop-java/src/main/resources/application.yml
  29. 84 0
      接口实现进度-第9批-admin.md
  30. 16 0
      联调说明.md

+ 389 - 0
tools/test_admin_api.py

@@ -0,0 +1,389 @@
+# -*- coding: utf-8 -*-
+"""admin 端 24 个接口的端到端验证。
+
+覆盖:
+  1. 登录:token 落 Redis(yoshop:admin:token:{token},值为管理员 JSON、无 password、TTL 7 天)
+  2. 未登录 / 错误 token 访问业务接口 → 401
+  3. admin.user/detail、renew(改回原值,不污染数据)
+  4. store/index、recycle、edit(原值回写)、recovery→move 回收站往返、add(免费版拒绝)
+  5. store.api:树列表、新增、编辑、删除(子级保护)
+  6. store.menu:树列表、详情、新增、编辑、set_apis(空值拒绝)、删除(子级保护)
+  7. setting.cache/clear(只清 data,不清登录态)
+  8. setting.science/info 结构
+  9. setting.timer/test 与 yoshop_system_process 一致性
+ 10. logout 后 token 失效
+
+前置:Java 服务在 18080;admin 账号口令临时改为 123456(脚本只管业务,口令由外部脚本改)。
+"""
+import json
+import re
+import socket
+import sys
+
+import pymysql
+import requests
+
+BASE = "http://127.0.0.1:18080"
+REDIS = ("192.168.1.177", 6379, "@@Qinys12346..")
+PREFIX = "yoshop:admin:token:"
+STORE_PREFIX = "yoshop:store:token:"
+
+YML = r"F:/shop/java-rewrite/yoshop-java/src/main/resources/application.yml"
+DB_PWD = re.findall(r"password:\s*(.+)", open(YML, encoding="utf-8").read())[0].strip().strip("\"'")
+
+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:
+        if isinstance(a, bytes):
+            b = a
+        elif isinstance(a, str):
+            b = a.encode()
+        else:
+            b = str(a).encode()
+        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(errors="replace")
+    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(errors="replace")
+    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)
+
+
+# ---------------------------------------------------------------- HTTP
+def api(method, path, token=None, body=None, params=None):
+    headers = {"Content-Type": "application/json"}
+    if token:
+        headers["Access-Token"] = token
+    url = BASE + path
+    if method == "GET":
+        r = S.get(url, headers=headers, params=params or {}, timeout=60)
+    else:
+        r = S.post(url, headers=headers, json=body or {}, params=params or {}, timeout=60)
+    try:
+        return r.json()
+    except Exception:
+        return {"status": -1, "message": r.text[:200], "data": []}
+
+
+def main():
+    conn = pymysql.connect(host="192.168.1.177", port=3306, user="root", password=DB_PWD,
+                           database="shop", autocommit=True, charset="utf8mb4")
+    cur = conn.cursor()
+    cur.execute("select store_id, remark, sort, is_recycle from yoshop_store where store_id = 10001")
+    store_snapshot = cur.fetchone()
+    cur.execute("select store_user_id, user_name from yoshop_store_user where store_id = 10001 and is_super = 1")
+    user_snapshot = cur.fetchone()
+
+    temp_ids = {"api": [], "menu": []}
+    try:
+        with Redis() as r:
+            print("== 1. 登录与登录态 ==")
+            res = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"})
+            check("登录成功", res.get("status") == 200 and res.get("message") == "登录成功",
+                  json.dumps(res, ensure_ascii=False))
+            data = res.get("data") or {}
+            token = data.get("token")
+            check("返回 userId / token", data.get("userId") == 10001 and isinstance(token, str) and len(token) == 32,
+                  json.dumps(data, ensure_ascii=False))
+            if not token:
+                return 1
+
+            key = PREFIX + token
+            raw = r("GET", key)
+            val = json.loads(raw) if raw else {}
+            if isinstance(val, str):
+                val = json.loads(val)
+            check("value 顶层是管理员字段", val.get("adminUserId") == 10001 and val.get("userName") == "admin",
+                  json.dumps(val, ensure_ascii=False)[:160])
+            check("不含 password", "password" not in val, sorted(val.keys()))
+            ttl = r("TTL", key)
+            check("TTL = 604800", isinstance(ttl, int) and 604795 <= ttl <= 604800, "ttl=%s" % ttl)
+
+            print("== 2. 未登录 / 错误 token ==")
+            no = api("GET", "/admin/store/index")
+            check("无 token → 401", no.get("status") == 401 and no.get("message") == "请先登录后再访问",
+                  json.dumps(no, ensure_ascii=False)[:160])
+            bad = api("GET", "/admin/store/index", token="0" * 32)
+            check("错误 token → 401", bad.get("status") == 401, json.dumps(bad, ensure_ascii=False)[:160])
+
+            print("== 3. admin.user/detail、renew ==")
+            detail = api("GET", "/admin/admin.user/detail", token=token)
+            ui = (detail.get("data") or {}).get("userInfo") or {}
+            check("detail 返回当前管理员", detail.get("status") == 200 and ui.get("adminUserId") == 10001,
+                  json.dumps(detail, ensure_ascii=False)[:200])
+            check("detail 不含 password", "password" not in ui, sorted(ui.keys()))
+            renew = api("POST", "/admin/admin.user/renew", token=token,
+                        body={"form": {"userName": "admin", "password": "", "passwordConfirm": ""}})
+            check("renew 空密码不修改 → 200", renew.get("status") == 200, json.dumps(renew, ensure_ascii=False)[:160])
+            mismatch = api("POST", "/admin/admin.user/renew", token=token,
+                           body={"form": {"userName": "admin", "password": "a123456", "passwordConfirm": "b123456"}})
+            check("两次密码不一致 → 确认密码不正确", mismatch.get("status") == 500
+                  and mismatch.get("message") == "确认密码不正确", json.dumps(mismatch, ensure_ascii=False)[:160])
+            still = api("POST", "/admin/passport/login", body={"username": "admin", "password": "123456"})
+            check("口令未被改坏(123456 仍可登录)", still.get("status") == 200,
+                  json.dumps(still, ensure_ascii=False)[:160])
+            if still.get("status") == 200:
+                r("DEL", PREFIX + still["data"]["token"])
+
+            print("== 4. 商城管理 ==")
+            idx = api("GET", "/admin/store/index", token=token)
+            lst = (idx.get("data") or {}).get("list") or {}
+            rows = lst.get("data") or []
+            check("store/index 200 且分页结构正确",
+                  idx.get("status") == 200 and lst.get("perPage") == 15 and isinstance(rows, list),
+                  json.dumps(idx, ensure_ascii=False)[:200])
+            first = rows[0] if rows else {}
+            check("列表字段驼峰且带 superUser",
+                  first.get("storeId") == 10001 and (first.get("superUser") or {}).get("userName") == "admin"
+                  and "logoImage" not in first, json.dumps(first, ensure_ascii=False)[:240])
+
+            add = api("POST", "/admin/store/add", token=token)
+            check("store/add 免费版拒绝", add.get("status") == 500
+                  and add.get("message") == "很抱歉,免费版暂不支持多开商城", json.dumps(add, ensure_ascii=False)[:160])
+
+            edit = api("POST", "/admin/store/edit", token=token,
+                       body={"storeId": 10001, "form": {"userName": user_snapshot[1],
+                                                        "password": "", "passwordConfirm": "",
+                                                        "remark": store_snapshot[1], "sort": store_snapshot[2]}})
+            check("store/edit 原值回写 → 200", edit.get("status") == 200, json.dumps(edit, ensure_ascii=False)[:200])
+
+            rec = api("POST", "/admin/store/recovery", token=token, body={"storeId": 10001})
+            cur.execute("select is_recycle from yoshop_store where store_id = 10001")
+            recycled = cur.fetchone()[0]
+            check("recovery 移入回收站(is_recycle=1)", rec.get("status") == 200 and recycled == 1,
+                  "status=%s is_recycle=%s" % (rec.get("status"), recycled))
+            rec_list = api("GET", "/admin/store/recycle", token=token)
+            rec_rows = ((rec_list.get("data") or {}).get("list") or {}).get("data") or []
+            check("回收站列表能看到该商城", any(x.get("storeId") == 10001 for x in rec_rows),
+                  json.dumps(rec_rows, ensure_ascii=False)[:160])
+            mv = api("POST", "/admin/store/move", token=token, body={"storeId": 10001})
+            cur.execute("select is_recycle from yoshop_store where store_id = 10001")
+            moved = cur.fetchone()[0]
+            check("move 移出回收站(is_recycle=0)", mv.get("status") == 200 and moved == 0,
+                  "status=%s is_recycle=%s" % (mv.get("status"), moved))
+
+            print("== 5. API 权限点 ==")
+            tree = api("GET", "/admin/store.api/index", token=token)
+            tree_rows = (tree.get("data") or {}).get("list") or []
+            check("store.api/index 返回树", tree.get("status") == 200 and isinstance(tree_rows, list) and tree_rows,
+                  json.dumps(tree, ensure_ascii=False)[:200])
+            check("根节点有 children", any("children" in x for x in tree_rows), "根节点数=%d" % len(tree_rows))
+
+            add_api = api("POST", "/admin/store.api/add", token=token,
+                         body={"form": {"name": "临时权限A", "url": "tmp/a", "parentId": 0, "sort": 900}})
+            cur.execute("select api_id from yoshop_store_api where url = 'tmp/a' order by api_id desc limit 1")
+            row = cur.fetchone()
+            api_id = row[0] if row else None
+            if api_id:
+                temp_ids["api"].append(api_id)
+            check("store.api/add 成功", add_api.get("status") == 200 and api_id is not None,
+                  json.dumps(add_api, ensure_ascii=False)[:160])
+
+            child = api("POST", "/admin/store.api/add", token=token,
+                        body={"form": {"name": "临时权限A-子", "url": "tmp/a-child", "parentId": api_id, "sort": 901}})
+            cur.execute("select api_id from yoshop_store_api where url = 'tmp/a-child' order by api_id desc limit 1")
+            row = cur.fetchone()
+            child_id = row[0] if row else None
+            if child_id:
+                temp_ids["api"].append(child_id)
+            check("新增子权限成功", child.get("status") == 200 and child_id is not None,
+                  json.dumps(child, ensure_ascii=False)[:160])
+
+            edit_api = api("POST", "/admin/store.api/edit", token=token,
+                           body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": 0, "sort": 902}})
+            check("store.api/edit 成功", edit_api.get("status") == 200, json.dumps(edit_api, ensure_ascii=False)[:160])
+
+            cycle = api("POST", "/admin/store.api/edit", token=token,
+                        body={"apiId": api_id, "form": {"name": "临时权限A改", "parentId": child_id}})
+            check("上级选到自己的子级被拒", cycle.get("status") == 500
+                  and cycle.get("message") == "上级权限不允许设置为当前子权限",
+                  json.dumps(cycle, ensure_ascii=False)[:160])
+
+            del_parent = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id})
+            check("有子级时删除被拒", del_parent.get("status") == 500
+                  and del_parent.get("message") == "当前权限下存在子权限,请先删除",
+                  json.dumps(del_parent, ensure_ascii=False)[:160])
+
+            del_child = api("POST", "/admin/store.api/delete", token=token, body={"apiId": child_id})
+            del_parent2 = api("POST", "/admin/store.api/delete", token=token, body={"apiId": api_id})
+            check("删子级 + 删父级成功", del_child.get("status") == 200 and del_parent2.get("status") == 200,
+                  "%s / %s" % (del_child.get("status"), del_parent2.get("status")))
+            if del_parent2.get("status") == 200:
+                temp_ids["api"] = []
+
+            print("== 6. 后台菜单 ==")
+            menus = api("GET", "/admin/store.menu/index", token=token)
+            menu_rows = (menus.get("data") or {}).get("list") or []
+            check("store.menu/index 返回树", menus.get("status") == 200 and bool(menu_rows),
+                  json.dumps(menus, ensure_ascii=False)[:200])
+            check("菜单带 apiIds / 不含 menuApi",
+                  any("apiIds" in x for x in menu_rows) and all("menuApi" not in x for x in menu_rows),
+                  sorted(menu_rows[0].keys()) if menu_rows else "")
+
+            first_menu_id = menu_rows[0]["menuId"]
+            info = api("GET", "/admin/store.menu/info", token=token, params={"menuId": first_menu_id})
+            info_row = (info.get("data") or {}).get("info") or {}
+            check("store.menu/info 返回详情", info.get("status") == 200 and info_row.get("menuId") == first_menu_id,
+                  json.dumps(info, ensure_ascii=False)[:200])
+            check("详情不含 apiIds/children", "apiIds" not in info_row and "children" not in info_row,
+                  sorted(info_row.keys()))
+
+            add_menu = api("POST", "/admin/store.menu/add", token=token,
+                           body={"form": {"name": "临时菜单A", "path": "/tmp-a", "type": 10, "isPage": 1,
+                                          "parentId": 0, "sort": 900}})
+            cur.execute("select menu_id from yoshop_store_menu where path = '/tmp-a' order by menu_id desc limit 1")
+            row = cur.fetchone()
+            menu_id = row[0] if row else None
+            if menu_id:
+                temp_ids["menu"].append(menu_id)
+            check("store.menu/add 成功", add_menu.get("status") == 200 and menu_id is not None,
+                  json.dumps(add_menu, ensure_ascii=False)[:160])
+
+            edit_menu = api("POST", "/admin/store.menu/edit", token=token,
+                            body={"menuId": menu_id, "form": {"name": "临时菜单A改", "parentId": 0, "sort": 901}})
+            check("store.menu/edit 成功", edit_menu.get("status") == 200, json.dumps(edit_menu, ensure_ascii=False)[:160])
+
+            empty_apis = api("POST", "/admin/store.menu/set_apis", token=token,
+                             body={"menuId": menu_id, "form": {"apiIds": []}})
+            check("set_apis 空数组被拒", empty_apis.get("status") == 500
+                  and empty_apis.get("message") == "API权限不能为空",
+                  json.dumps(empty_apis, ensure_ascii=False)[:160])
+
+            bind = api("POST", "/admin/store.menu/set_apis", token=token,
+                       body={"menuId": menu_id, "form": {"apiIds": [1, 2]}})
+            cur.execute("select api_id from yoshop_store_menu_api where menu_id = %s order by api_id", (menu_id,))
+            bound = [x[0] for x in cur.fetchall()]
+            check("set_apis 绑定成功", bind.get("status") == 200 and bound == [1, 2],
+                  "status=%s bound=%s" % (bind.get("status"), bound))
+
+            del_menu = api("POST", "/admin/store.menu/delete", token=token, body={"menuId": menu_id})
+            cur.execute("select count(*) from yoshop_store_menu_api where menu_id = %s", (menu_id,))
+            left = cur.fetchone()[0]
+            check("删除菜单并清关联", del_menu.get("status") == 200 and left == 0,
+                  "status=%s left=%s" % (del_menu.get("status"), left))
+            if del_menu.get("status") == 200:
+                temp_ids["menu"] = []
+
+            print("== 7. 清理缓存 ==")
+            clear = api("POST", "/admin/setting.cache/clear", token=token, body={"form": {"item": ["data"]}})
+            check("cache/clear 返回 200", clear.get("status") == 200, json.dumps(clear, ensure_ascii=False)[:160])
+            check("非强制模式不清登录态", r("GET", key) is not None, "key=%s" % key)
+
+            print("== 8. 环境检测 ==")
+            sci = api("GET", "/admin/setting.science/info", token=token)
+            info_ = (sci.get("data") or {}).get("scienceInfo") or {}
+            groups = ["server", "phpinfo", "writeable"]
+            check("science/info 三段结构", sci.get("status") == 200 and all(g in info_ for g in groups),
+                  sorted(info_.keys()))
+            items = (info_.get("server") or []) + (info_.get("writeable") or [])
+            check("检测项形状 {name,key,value,status,remark}",
+                  items and all({"name", "key", "value", "status", "remark"} <= set(x.keys()) for x in items),
+                  json.dumps(items[:1], ensure_ascii=False)[:200])
+
+            print("== 9. 定时任务探活 ==")
+            cur.execute("select last_working_time from yoshop_system_process where `key` = 'timer'")
+            row = cur.fetchone()
+            last = row[0] if row else 0
+            alive = __import__("time").time() - last <= 10
+            timer = api("POST", "/admin/setting.timer/test", token=token)
+            expect = "恭喜您,定时任务已开启" if alive else "很抱歉,定时任务未开启"
+            check("timer/test 结果与 system_process 一致", timer.get("message") == expect,
+                  "expect=%s got=%s" % (expect, json.dumps(timer, ensure_ascii=False)[:160]))
+
+            print("== 10. 登出 ==")
+            out = api("POST", "/admin/passport/logout", token=token)
+            check("logout 200", out.get("status") == 200, json.dumps(out, ensure_ascii=False)[:160])
+            check("Redis 登录态已删除", r("GET", key) is None, "key=%s" % key)
+            after = api("GET", "/admin/store/index", token=token)
+            check("旧 token → 401", after.get("status") == 401, json.dumps(after, ensure_ascii=False)[:160])
+
+            print("== 11. 商家 token 不能访问 admin ==")
+            store_key = r("KEYS", STORE_PREFIX + "*")
+            if store_key:
+                fake = store_key[0].decode().replace(STORE_PREFIX, "") if isinstance(store_key[0], bytes) \
+                    else str(store_key[0]).replace(STORE_PREFIX, "")
+                cross = api("GET", "/admin/store/index", token=fake)
+                check("商家 token 访问 /admin → 401", cross.get("status") == 401,
+                      json.dumps(cross, ensure_ascii=False)[:160])
+            else:
+                print("  [skip] 当前无商家登录态可借用")
+    finally:
+        # 还原商城数据
+        cur.execute("update yoshop_store set remark = %s, sort = %s, is_recycle = %s where store_id = 10001",
+                    (store_snapshot[1], store_snapshot[2], store_snapshot[3]))
+        cur.execute("update yoshop_store_user set user_name = %s where store_user_id = %s",
+                    (user_snapshot[1], user_snapshot[0]))
+        # 清理临时数据
+        for api_id in temp_ids["api"]:
+            cur.execute("delete from yoshop_store_menu_api where api_id = %s", (api_id,))
+            cur.execute("delete from yoshop_store_api where api_id = %s", (api_id,))
+        for menu_id in temp_ids["menu"]:
+            cur.execute("delete from yoshop_store_menu_api where menu_id = %s", (menu_id,))
+            cur.execute("delete from yoshop_store_menu where menu_id = %s", (menu_id,))
+        cur.execute("delete from yoshop_store_api where url in ('tmp/a', 'tmp/a-child')")
+        cur.execute("delete from yoshop_store_menu where path = '/tmp-a'")
+        conn.close()
+
+    print("\n通过 %d / 失败 %d" % (passed, failed))
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 33 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminCacheController.java

@@ -0,0 +1,33 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminCacheService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.util.Params;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 清理缓存,对齐 PHP app/admin/controller/setting/Cache(URL: /admin/setting.cache/**)。
+ * 入参:{item: ['data'|'temp'], isForce: bool}。
+ */
+@RestController
+@RequestMapping("/admin/setting.cache")
+public class AdminCacheController {
+
+    private final AdminCacheService adminCacheService;
+
+    public AdminCacheController(AdminCacheService adminCacheService) {
+        this.adminCacheService = adminCacheService;
+    }
+
+    @RequestMapping(value = "/clear", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> clear(@RequestBody(required = false) Map<String, Object> body) {
+        Map<String, Object> form = Params.form(body);
+        adminCacheService.clear(form);
+        return R.okMsg("操作成功");
+    }
+}

+ 41 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminPassportController.java

@@ -0,0 +1,41 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminUserService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.util.Params;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 平台超管认证,对齐 PHP app/admin/controller/Passport(URL: /admin/passport/**)。
+ */
+@RestController
+@RequestMapping("/admin/passport")
+@RequiredArgsConstructor
+public class AdminPassportController {
+
+    private final AdminUserService adminUserService;
+
+    /** 登录:返回 {userId, token},message = 登录成功 */
+    @PostMapping("/login")
+    public R<Map<String, Object>> login(@RequestBody(required = false) Map<String, Object> body,
+                                        HttpServletRequest request) {
+        Map<String, Object> params = Params.merge(body, request);
+        String username = Params.strOrNull(params, "username");
+        String password = Params.strOrNull(params, "password");
+        return R.ok(adminUserService.login(username, password), "登录成功");
+    }
+
+    /** 退出登录:清 Redis 登录态 */
+    @RequestMapping("/logout")
+    public R<Void> logout(HttpServletRequest request) {
+        adminUserService.logout(request.getHeader("Access-Token"));
+        return R.okMsg("操作成功");
+    }
+}

+ 26 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminScienceController.java

@@ -0,0 +1,26 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminScienceService;
+import cn.yoshop.common.api.R;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 环境检测,对齐 PHP app/admin/controller/setting/Science(URL: /admin/setting.science/**)。
+ */
+@RestController
+@RequestMapping("/admin/setting.science")
+@RequiredArgsConstructor
+public class AdminScienceController {
+
+    private final AdminScienceService adminScienceService;
+
+    @RequestMapping(value = "/info", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> info() {
+        return R.ok(adminScienceService.info());
+    }
+}

+ 59 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreApiController.java

@@ -0,0 +1,59 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminApiService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.StoreApi;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.util.Params;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 商家后台 API 权限点维护,对齐 PHP app/admin/controller/store/Api(URL: /admin/store.api/**)。
+ */
+@RestController
+@RequestMapping("/admin/store.api")
+@RequiredArgsConstructor
+public class AdminStoreApiController {
+
+    private final AdminApiService adminApiService;
+
+    /** 权限树(parentId 组树,子节点为 children) */
+    @RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, List<StoreApi>>> index() {
+        return R.ok(Map.of("list", adminApiService.getTree()));
+    }
+
+    @RequestMapping(value = "/add", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> add(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminApiService.add(Params.form(body));
+        return R.okMsg("添加成功");
+    }
+
+    @RequestMapping(value = "/edit", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> edit(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminApiService.edit(requireApiId(body, request), Params.form(body));
+        return R.okMsg("更新成功");
+    }
+
+    @RequestMapping(value = "/delete", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> delete(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminApiService.remove(requireApiId(body, request));
+        return R.okMsg("删除成功");
+    }
+
+    private int requireApiId(Map<String, Object> body, HttpServletRequest request) {
+        Integer apiId = Params.argInt(Params.merge(body, request), "apiId");
+        if (apiId == null || apiId <= 0) {
+            throw BizException.silent("参数错误:apiId");
+        }
+        return apiId;
+    }
+}

+ 80 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreController.java

@@ -0,0 +1,80 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminStoreService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 商城(商户)管理,对齐 PHP app/admin/controller/Store(URL: /admin/store/**)。
+ *
+ * <p>注意:recovery = 移入回收站、move = 移出回收站,PHP 命名如此,照搬。
+ */
+@RestController
+@RequestMapping("/admin/store")
+@RequiredArgsConstructor
+public class AdminStoreController {
+
+    private final AdminStoreService adminStoreService;
+
+    /** 商城列表(每页 15,带 superUser) */
+    @RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, PageVO<Map<String, Object>>>> index(
+            @RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        PageVO<Map<String, Object>> list = adminStoreService.getList(Params.merge(body, request), false);
+        return R.ok(Map.of("list", list));
+    }
+
+    /** 新增商城:免费版固定拒绝 */
+    @RequestMapping(value = "/add", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> add() {
+        adminStoreService.add();
+        return R.okMsg("添加成功");
+    }
+
+    /** 回收站列表 */
+    @RequestMapping(value = "/recycle", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, PageVO<Map<String, Object>>>> recycle(
+            @RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        PageVO<Map<String, Object>> list = adminStoreService.getList(Params.merge(body, request), true);
+        return R.ok(Map.of("list", list));
+    }
+
+    /** 移入回收站 */
+    @RequestMapping(value = "/recovery", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> recovery(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminStoreService.recycle(requireStoreId(body, request), true);
+        return R.okMsg("操作成功");
+    }
+
+    /** 修改商家账户(商城备注/排序 + 超级管理员账号密码) */
+    @RequestMapping(value = "/edit", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> edit(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminStoreService.edit(requireStoreId(body, request), Params.form(body));
+        return R.okMsg("操作成功");
+    }
+
+    /** 移出回收站 */
+    @RequestMapping(value = "/move", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> move(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminStoreService.recycle(requireStoreId(body, request), false);
+        return R.okMsg("操作成功");
+    }
+
+    private int requireStoreId(Map<String, Object> body, HttpServletRequest request) {
+        Integer storeId = Params.argInt(Params.merge(body, request), "storeId");
+        if (storeId == null || storeId <= 0) {
+            throw BizException.silent("参数错误:storeId");
+        }
+        return storeId;
+    }
+}

+ 80 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminStoreMenuController.java

@@ -0,0 +1,80 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.entity.AdminMenu;
+import cn.yoshop.admin.service.AdminMenuService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.util.Params;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 商家后台菜单维护,对齐 PHP app/admin/controller/store/Menu(URL: /admin/store.menu/**)。
+ * 注意 URL 里的点号与下划线:setApis → /set_apis。
+ */
+@RestController
+@RequestMapping("/admin/store.menu")
+@RequiredArgsConstructor
+public class AdminStoreMenuController {
+
+    private final AdminMenuService adminMenuService;
+
+    /** 菜单树(含 apiIds) */
+    @RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, List<AdminMenu>>> index() {
+        return R.ok(Map.of("list", adminMenuService.getTree()));
+    }
+
+    /** 菜单详情 */
+    @RequestMapping(value = "/info", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> info(@RequestBody(required = false) Map<String, Object> body,
+                                       HttpServletRequest request) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("info", adminMenuService.getInfo(requireMenuId(body, request)));
+        return R.ok(data);
+    }
+
+    @RequestMapping(value = "/add", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> add(@RequestBody(required = false) Map<String, Object> body) {
+        adminMenuService.add(Params.form(body));
+        return R.okMsg("添加成功");
+    }
+
+    @RequestMapping(value = "/edit", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> edit(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminMenuService.edit(requireMenuId(body, request), Params.form(body));
+        return R.okMsg("更新成功");
+    }
+
+    /** 设置菜单绑定的 API 权限 */
+    @RequestMapping(value = "/set_apis", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> setApis(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        Map<String, Object> params = Params.merge(body, request);
+        int menuId = requireMenuId(body, request);
+        List<Integer> apiIds = Params.intList(params.get("apiIds"));
+        adminMenuService.setApis(menuId, apiIds);
+        return R.okMsg("操作成功");
+    }
+
+    @RequestMapping(value = "/delete", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> delete(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminMenuService.remove(requireMenuId(body, request));
+        return R.okMsg("删除成功");
+    }
+
+    private int requireMenuId(Map<String, Object> body, HttpServletRequest request) {
+        Integer menuId = Params.argInt(Params.merge(body, request), "menuId");
+        if (menuId == null || menuId <= 0) {
+            throw BizException.silent("参数错误:menuId");
+        }
+        return menuId;
+    }
+}

+ 25 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminTimerController.java

@@ -0,0 +1,25 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminTimerService;
+import cn.yoshop.common.api.R;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 定时任务探活,对齐 PHP app/admin/controller/setting/Timer(URL: /admin/setting.timer/**)。
+ * 注意:内部会等待 3 秒,前端需按慢接口处理。
+ */
+@RestController
+@RequestMapping("/admin/setting.timer")
+@RequiredArgsConstructor
+public class AdminTimerController {
+
+    private final AdminTimerService adminTimerService;
+
+    @RequestMapping(value = "/test", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> test() {
+        return R.okMsg(adminTimerService.test());
+    }
+}

+ 42 - 0
yoshop-java/src/main/java/cn/yoshop/admin/controller/AdminUserController.java

@@ -0,0 +1,42 @@
+package cn.yoshop.admin.controller;
+
+import cn.yoshop.admin.service.AdminUserService;
+import cn.yoshop.common.api.R;
+import cn.yoshop.common.entity.AdminUser;
+import cn.yoshop.common.util.Params;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 平台超管个人资料,对齐 PHP app/admin/controller/admin/User(URL: /admin/admin.user/**)。
+ */
+@RestController
+@RequestMapping("/admin/admin.user")
+@RequiredArgsConstructor
+public class AdminUserController {
+
+    private final AdminUserService adminUserService;
+
+    /** 当前登录管理员信息(取自 Redis 登录态,不回查数据库) */
+    @RequestMapping(value = "/detail", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Map<String, Object>> detail(HttpServletRequest request) {
+        AdminUser user = adminUserService.getLoginUser(request.getHeader("Access-Token"));
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("userInfo", user);
+        return R.ok(data);
+    }
+
+    /** 修改账号/密码,并刷新登录态缓存 */
+    @RequestMapping(value = "/renew", method = {RequestMethod.GET, RequestMethod.POST})
+    public R<Void> renew(@RequestBody(required = false) Map<String, Object> body, HttpServletRequest request) {
+        adminUserService.renew(request.getHeader("Access-Token"), Params.form(body));
+        return R.okMsg("更新成功");
+    }
+}

+ 72 - 0
yoshop-java/src/main/java/cn/yoshop/admin/entity/AdminMenu.java

@@ -0,0 +1,72 @@
+package cn.yoshop.admin.entity;
+
+import cn.yoshop.common.util.TimeUtil;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 平台后台视角的商家菜单,对应 yoshop_store_menu。
+ *
+ * <p>与商家端 {@link cn.yoshop.common.entity.StoreMenu} 的区别:商家端隐藏
+ * action_mark / sort / create_time / update_time 并输出 menuApi;
+ * 平台端(PHP app/admin/model/store/Menu)不隐藏这些字段,而是隐藏 menuApi、补出 apiIds,
+ * 字段集不同,故单独建模,避免把两套出参搅在一起。
+ */
+@Data
+@TableName("yoshop_store_menu")
+public class AdminMenu {
+
+    @TableId(value = "menu_id", type = IdType.AUTO)
+    private Integer menuId;
+
+    /** 10=页面 20=操作 */
+    private Integer type;
+
+    private String name;
+
+    private String path;
+
+    private Integer isPage;
+
+    private String moduleKey;
+
+    private String actionMark;
+
+    private Integer parentId;
+
+    private Integer sort;
+
+    @JsonIgnore
+    private Integer createTime;
+
+    @JsonIgnore
+    private Integer updateTime;
+
+    @JsonProperty("createTime")
+    public String getCreateTimeText() {
+        return TimeUtil.format(createTime);
+    }
+
+    @JsonProperty("updateTime")
+    public String getUpdateTimeText() {
+        return TimeUtil.format(updateTime);
+    }
+
+    /** 菜单绑定的 API ID 集(列表接口填充;未绑定 API 时不输出该键,对齐 PHP) */
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    @TableField(exist = false)
+    private List<Integer> apiIds;
+
+    /** 子菜单(树状列表用) */
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    @TableField(exist = false)
+    private List<AdminMenu> children;
+}

+ 12 - 0
yoshop-java/src/main/java/cn/yoshop/admin/mapper/AdminMenuMapper.java

@@ -0,0 +1,12 @@
+package cn.yoshop.admin.mapper;
+
+import cn.yoshop.admin.entity.AdminMenu;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 平台后台视角的菜单映射(同一张 yoshop_store_menu,出参字段集与商家端不同,故单独建模)。
+ */
+@Mapper
+public interface AdminMenuMapper extends BaseMapper<AdminMenu> {
+}

+ 139 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminApiService.java

@@ -0,0 +1,139 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.common.entity.StoreApi;
+import cn.yoshop.common.entity.StoreMenuApi;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.StoreApiMapper;
+import cn.yoshop.common.mapper.StoreMenuApiMapper;
+import cn.yoshop.common.util.Params;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 商家后台 API 权限点维护,对齐 PHP app/admin/model/store/Api:
+ * 树状列表、增改(防环)、删除(先校验子级并清菜单关联)。
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminApiService {
+
+    private final StoreApiMapper storeApiMapper;
+    private final StoreMenuApiMapper storeMenuApiMapper;
+
+    /** 权限树:sort asc, create_time asc,按 parent_id 组树 */
+    public List<StoreApi> getTree() {
+        List<StoreApi> all = getAll();
+        return buildTree(all, 0);
+    }
+
+    /** 新增权限 */
+    @Transactional(rollbackFor = Exception.class)
+    public void add(Map<String, Object> form) {
+        StoreApi api = new StoreApi();
+        api.setName(Params.str(form, "name", ""));
+        api.setUrl(Params.str(form, "url", ""));
+        Integer parentId = Params.intOrNull(form, "parentId");
+        api.setParentId(parentId == null ? 0 : parentId);
+        Integer sort = Params.intOrNull(form, "sort");
+        api.setSort(sort == null ? 100 : sort);
+        int now = (int) (System.currentTimeMillis() / 1000);
+        api.setCreateTime(now);
+        api.setUpdateTime(now);
+        storeApiMapper.insert(api);
+    }
+
+    /** 编辑权限:上级不允许选到自己的子级(否则成环) */
+    @Transactional(rollbackFor = Exception.class)
+    public void edit(int apiId, Map<String, Object> form) {
+        StoreApi api = requireApi(apiId);
+        Integer parentId = Params.intOrNull(form, "parentId");
+        if (parentId != null && parentId > 0 && getTopApiIds(parentId).contains(apiId)) {
+            throw BizException.silent("上级权限不允许设置为当前子权限");
+        }
+
+        StoreApi update = new StoreApi();
+        update.setApiId(apiId);
+        String name = Params.strOrNull(form, "name");
+        String url = Params.strOrNull(form, "url");
+        Integer sort = Params.intOrNull(form, "sort");
+        update.setName(name != null ? name : api.getName());
+        update.setUrl(url != null ? url : api.getUrl());
+        update.setParentId(parentId != null ? parentId : api.getParentId());
+        update.setSort(sort != null ? sort : api.getSort());
+        update.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+        storeApiMapper.updateById(update);
+    }
+
+    /** 删除权限:有子级先拒绝,否则清菜单关联后删除 */
+    @Transactional(rollbackFor = Exception.class)
+    public void remove(int apiId) {
+        requireApi(apiId);
+        boolean hasChild = storeApiMapper.selectCount(Wrappers.<StoreApi>lambdaQuery()
+                .eq(StoreApi::getParentId, apiId)) > 0;
+        if (hasChild) {
+            throw BizException.silent("当前权限下存在子权限,请先删除");
+        }
+        storeMenuApiMapper.delete(Wrappers.<StoreMenuApi>lambdaQuery()
+                .eq(StoreMenuApi::getApiId, apiId));
+        storeApiMapper.deleteById(apiId);
+    }
+
+    private StoreApi requireApi(int apiId) {
+        StoreApi api = storeApiMapper.selectById(apiId);
+        if (api == null) {
+            throw BizException.silent("很抱歉,该权限不存在");
+        }
+        return api;
+    }
+
+    private List<StoreApi> getAll() {
+        return storeApiMapper.selectList(Wrappers.<StoreApi>lambdaQuery()
+                .orderByAsc(StoreApi::getSort)
+                .orderByAsc(StoreApi::getCreateTime));
+    }
+
+    /** 树状递归:与 PHP getTreeData 一致,先序收集、同级保持原排序 */
+    private List<StoreApi> buildTree(List<StoreApi> all, int parentId) {
+        List<StoreApi> data = new ArrayList<>();
+        for (StoreApi item : all) {
+            if (item.getParentId() != null && item.getParentId() == parentId) {
+                List<StoreApi> children = buildTree(all, item.getApiId());
+                if (!children.isEmpty()) {
+                    item.setChildren(children);
+                }
+                data.add(item);
+            }
+        }
+        return data;
+    }
+
+    /** 所有上级 ID 集(PHP getTopApiIds):用于判断是否成环 */
+    private Set<Integer> getTopApiIds(int apiId) {
+        List<StoreApi> all = getAll();
+        Set<Integer> ids = new HashSet<>();
+        int cursor = apiId;
+        // 迭代向上找,避免脏数据成环时无限递归
+        while (cursor > 0) {
+            Integer parent = null;
+            for (StoreApi item : all) {
+                if (item.getApiId() == cursor) {
+                    parent = item.getParentId();
+                    break;
+                }
+            }
+            if (parent == null || parent <= 0 || !ids.add(parent)) {
+                break;
+            }
+            cursor = parent;
+        }
+        return ids;
+    }
+}

+ 116 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminCacheService.java

@@ -0,0 +1,116 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.common.util.Params;
+import cn.yoshop.utils.RedisUtils;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+/**
+ * 清理缓存,对齐 PHP app/admin/service/Cache:
+ *
+ * <ul>
+ *   <li>item 含 {@code data}:清业务缓存;{@code isForce} 为真时清全部 yoshop:* 键(会把登录态一起清掉,
+ *       与 PHP {@code cache->clear()} 语义一致),否则只清 {@code yoshop:cache:*} 前缀</li>
+ *   <li>item 含 {@code temp}:清临时目录(PHP 清 web/temp、runtime/image、runtime/local)</li>
+ * </ul>
+ *
+ * <p>文件清理只允许在配置的目录内进行:目录取 {@code yoshop.temp-cache-paths},
+ * 删除前校验目标必须是工作目录下的真实子目录,且保留 .gitignore。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AdminCacheService {
+
+    /** 业务缓存统一前缀(Java 端约定;PHP 的 tag 机制不再使用) */
+    private static final String CACHE_PREFIX = "yoshop:cache:";
+
+    /** 全部应用键前缀(强制模式用) */
+    private static final String ALL_PREFIX = "yoshop:*";
+
+    private final RedisUtils redis;
+
+    /** 临时文件目录,逗号分隔;相对路径按启动目录解析 */
+    @Value("${yoshop.temp-cache-paths:runtime/temp,runtime/image,runtime/local}")
+    private String tempCachePaths;
+
+    public void clear(Map<String, Object> form) {
+        List<String> items = toStringList(form.get("item"));
+        boolean force = Params.boolVal(form, "isForce");
+
+        if (items.contains("data")) {
+            if (force) {
+                redis.deleteByPrefix(ALL_PREFIX);
+            } else {
+                redis.deleteByPrefix(CACHE_PREFIX);
+            }
+        }
+        if (items.contains("temp")) {
+            for (String path : tempCachePaths.split(",")) {
+                deleteChildren(path.trim());
+            }
+        }
+    }
+
+    /** 递归删除目录内的文件与子目录,保留目录自身与 .gitignore */
+    private void deleteChildren(String relativePath) {
+        if (relativePath.isEmpty()) {
+            return;
+        }
+        Path base = Paths.get(relativePath).toAbsolutePath().normalize();
+        Path workDir = Paths.get("").toAbsolutePath().normalize();
+        // 安全护栏:只处理工作目录下的子目录,避免配置写错误删系统目录
+        if (!base.startsWith(workDir) || base.equals(workDir) || !Files.isDirectory(base)) {
+            return;
+        }
+        try (Stream<Path> walk = Files.walk(base)) {
+            List<Path> targets = walk
+                    .filter(p -> !p.equals(base))
+                    .filter(p -> !".gitignore".equals(String.valueOf(p.getFileName())))
+                    .sorted(Comparator.reverseOrder())
+                    .toList();
+            for (Path path : targets) {
+                if (!path.startsWith(base)) {
+                    continue;
+                }
+                try {
+                    Files.deleteIfExists(path);
+                } catch (IOException e) {
+                    log.warn("删除临时文件失败 {} : {}", path, e.getMessage());
+                }
+            }
+        } catch (IOException e) {
+            log.warn("清理临时目录失败 {} : {}", base, e.getMessage());
+        }
+    }
+
+    private List<String> toStringList(Object value) {
+        List<String> out = new ArrayList<>();
+        if (value instanceof List<?> list) {
+            for (Object item : list) {
+                if (item != null) {
+                    out.add(String.valueOf(item));
+                }
+            }
+        } else if (value instanceof String s && !s.isBlank()) {
+            for (String part : s.split(",")) {
+                if (!part.isBlank()) {
+                    out.add(part.trim());
+                }
+            }
+        }
+        return out;
+    }
+}

+ 199 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminMenuService.java

@@ -0,0 +1,199 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.admin.entity.AdminMenu;
+import cn.yoshop.admin.mapper.AdminMenuMapper;
+import cn.yoshop.common.entity.StoreMenuApi;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.StoreMenuApiMapper;
+import cn.yoshop.common.util.Params;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 商家后台菜单维护,对齐 PHP app/admin/model/store/Menu(+ MenuApi):
+ * 树状列表(带 apiIds)、详情、增改(防环)、绑定 API、删除(先校验子级)。
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminMenuService {
+
+    private final AdminMenuMapper adminMenuMapper;
+    private final StoreMenuApiMapper storeMenuApiMapper;
+
+    /** 菜单树:sort asc, create_time asc;每个节点补 apiIds(未绑定 API 时不输出该键) */
+    public List<AdminMenu> getTree() {
+        List<AdminMenu> all = adminMenuMapper.selectList(Wrappers.<AdminMenu>lambdaQuery()
+                .orderByAsc(AdminMenu::getSort)
+                .orderByAsc(AdminMenu::getCreateTime));
+        if (all.isEmpty()) {
+            return all;
+        }
+        List<Integer> menuIds = all.stream().map(AdminMenu::getMenuId).toList();
+        Map<Integer, List<Integer>> apiIdsByMenu = storeMenuApiMapper.selectList(
+                        Wrappers.<StoreMenuApi>lambdaQuery().in(StoreMenuApi::getMenuId, menuIds))
+                .stream()
+                .collect(Collectors.groupingBy(StoreMenuApi::getMenuId,
+                        Collectors.mapping(StoreMenuApi::getApiId, Collectors.toList())));
+        all.forEach(m -> m.setApiIds(apiIdsByMenu.get(m.getMenuId())));
+        return buildTree(all, 0);
+    }
+
+    /** 菜单详情(对齐 PHP Menu::detail:不含 apiIds / children) */
+    public AdminMenu getInfo(int menuId) {
+        AdminMenu menu = adminMenuMapper.selectById(menuId);
+        if (menu == null) {
+            throw BizException.silent("很抱歉,该菜单不存在");
+        }
+        return menu;
+    }
+
+    /** 新增菜单:字段与 PHP save($data) 一致,缺省值取表默认 */
+    @Transactional(rollbackFor = Exception.class)
+    public void add(Map<String, Object> form) {
+        AdminMenu menu = new AdminMenu();
+        applyForm(menu, form);
+        menu.setType(orDefault(Params.intOrNull(form, "type"), 10));
+        menu.setIsPage(orDefault(Params.intOrNull(form, "isPage"), 1));
+        menu.setParentId(orDefault(Params.intOrNull(form, "parentId"), 0));
+        menu.setSort(orDefault(Params.intOrNull(form, "sort"), 100));
+        menu.setName(orDefault(menu.getName(), ""));
+        menu.setPath(orDefault(menu.getPath(), ""));
+        menu.setModuleKey(orDefault(menu.getModuleKey(), ""));
+        menu.setActionMark(orDefault(menu.getActionMark(), ""));
+        int now = (int) (System.currentTimeMillis() / 1000);
+        menu.setCreateTime(now);
+        menu.setUpdateTime(now);
+        adminMenuMapper.insert(menu);
+    }
+
+    /** 编辑菜单:上级不允许选到自己的子级,否则成环 */
+    @Transactional(rollbackFor = Exception.class)
+    public void edit(int menuId, Map<String, Object> form) {
+        AdminMenu menu = getInfo(menuId);
+        Integer parentId = Params.intOrNull(form, "parentId");
+        if (parentId != null && parentId > 0 && getTopMenuIds(parentId).contains(menuId)) {
+            throw BizException.silent("上级菜单不允许设置为当前子菜单");
+        }
+
+        AdminMenu update = new AdminMenu();
+        update.setMenuId(menuId);
+        applyForm(update, form);
+        update.setType(orDefault(Params.intOrNull(form, "type"), menu.getType()));
+        update.setIsPage(orDefault(Params.intOrNull(form, "isPage"), menu.getIsPage()));
+        update.setParentId(orDefault(parentId, menu.getParentId()));
+        update.setSort(orDefault(Params.intOrNull(form, "sort"), menu.getSort()));
+        update.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+        adminMenuMapper.updateById(update);
+    }
+
+    /**
+     * 绑定菜单的 API 权限,对齐 PHP Menu::setApis:
+     * apiIds 不能为空,先按 menu_id 清空再批量插入(同一事务)。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void setApis(int menuId, List<Integer> apiIds) {
+        getInfo(menuId);
+        if (apiIds == null || apiIds.isEmpty()) {
+            throw BizException.silent("API权限不能为空");
+        }
+        storeMenuApiMapper.delete(Wrappers.<StoreMenuApi>lambdaQuery()
+                .eq(StoreMenuApi::getMenuId, menuId));
+        int now = (int) (System.currentTimeMillis() / 1000);
+        for (Integer apiId : apiIds) {
+            if (apiId == null || apiId <= 0) {
+                continue;
+            }
+            StoreMenuApi rel = new StoreMenuApi();
+            rel.setMenuId(menuId);
+            rel.setApiId(apiId);
+            rel.setCreateTime(now);
+            storeMenuApiMapper.insert(rel);
+        }
+    }
+
+    /** 删除菜单:有子级先拒绝,否则清 API 关联后删除 */
+    @Transactional(rollbackFor = Exception.class)
+    public void remove(int menuId) {
+        getInfo(menuId);
+        boolean hasChild = adminMenuMapper.selectCount(Wrappers.<AdminMenu>lambdaQuery()
+                .eq(AdminMenu::getParentId, menuId)) > 0;
+        if (hasChild) {
+            throw BizException.silent("当前菜单下存在子菜单或操作,请先删除");
+        }
+        storeMenuApiMapper.delete(Wrappers.<StoreMenuApi>lambdaQuery()
+                .eq(StoreMenuApi::getMenuId, menuId));
+        adminMenuMapper.deleteById(menuId);
+    }
+
+    /** 树状递归:与 PHP getTreeData 一致,同级保持 SQL 排序 */
+    private List<AdminMenu> buildTree(List<AdminMenu> all, int parentId) {
+        List<AdminMenu> data = new ArrayList<>();
+        for (AdminMenu item : all) {
+            if (item.getParentId() != null && item.getParentId() == parentId) {
+                List<AdminMenu> children = buildTree(all, item.getMenuId());
+                if (!children.isEmpty()) {
+                    item.setChildren(children);
+                }
+                data.add(item);
+            }
+        }
+        return data;
+    }
+
+    private void applyForm(AdminMenu menu, Map<String, Object> form) {
+        String name = Params.strOrNull(form, "name");
+        String path = Params.strOrNull(form, "path");
+        String moduleKey = Params.strOrNull(form, "moduleKey");
+        String actionMark = Params.strOrNull(form, "actionMark");
+        if (name != null) {
+            menu.setName(name);
+        }
+        if (path != null) {
+            menu.setPath(path);
+        }
+        if (moduleKey != null) {
+            menu.setModuleKey(moduleKey);
+        }
+        if (actionMark != null) {
+            menu.setActionMark(actionMark);
+        }
+    }
+
+    /** 所有上级菜单 ID 集(PHP getTopMenuIds),迭代实现避免脏数据成环 */
+    private Set<Integer> getTopMenuIds(int menuId) {
+        List<AdminMenu> all = adminMenuMapper.selectList(null);
+        Set<Integer> ids = new HashSet<>();
+        int cursor = menuId;
+        while (cursor > 0) {
+            Integer parent = null;
+            for (AdminMenu item : all) {
+                if (item.getMenuId() == cursor) {
+                    parent = item.getParentId();
+                    break;
+                }
+            }
+            if (parent == null || parent <= 0 || !ids.add(parent)) {
+                break;
+            }
+            cursor = parent;
+        }
+        return ids;
+    }
+
+    private static Integer orDefault(Integer value, Integer def) {
+        return value != null ? value : def;
+    }
+
+    private static String orDefault(String value, String def) {
+        return value != null ? value : def;
+    }
+}

+ 179 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminScienceService.java

@@ -0,0 +1,179 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.utils.RedisUtils;
+import lombok.RequiredArgsConstructor;
+import org.apache.catalina.util.ServerInfo;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.sql.DataSource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+
+/**
+ * 环境检测,对齐 PHP app/admin/controller/setting/Science::info 的出参结构:
+ * {@code {scienceInfo: {server: [...], phpinfo: [...], writeable: [...]}}},
+ * 每项形状都是 {name, key, value, status: normal|warning|danger, remark}。
+ *
+ * <p>检测内容换成 Java 运行时的真实指标(JDK、容器、内存、编码、时区、数据库/Redis 连通性、目录可写),
+ * 不再输出 PHP 版本与扩展;三段分组键保持不变,前端按 name/value/status 渲染无需改动。
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminScienceService {
+
+    /** JDK 最低要求,与 Spring Boot 3.x 一致 */
+    private static final int MIN_JDK = 17;
+
+    private final DataSource dataSource;
+    private final RedisUtils redis;
+
+    @Value("${yoshop.upload-path:runtime/uploads}")
+    private String uploadPath;
+
+    @Value("${yoshop.export-path:runtime/downloads}")
+    private String exportPath;
+
+    @Value("${yoshop.temp-cache-paths:runtime/temp,runtime/image,runtime/local}")
+    private String tempCachePaths;
+
+    public Map<String, Object> info() {
+        Map<String, Object> scienceInfo = new LinkedHashMap<>();
+        scienceInfo.put("server", server());
+        scienceInfo.put("phpinfo", environment());
+        scienceInfo.put("writeable", writeable());
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("scienceInfo", scienceInfo);
+        return data;
+    }
+
+    /** 服务器信息 */
+    private List<Map<String, Object>> server() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        String os = System.getProperty("os.name", "");
+        list.add(item("服务器操作系统", "system", os + " " + System.getProperty("os.version", ""),
+                os.toLowerCase().contains("linux") ? "normal" : "warning",
+                "建议使用 Linux 系统以提升程序性能"));
+
+        String container;
+        try {
+            container = ServerInfo.getServerInfo();
+        } catch (Throwable t) {
+            container = "Spring Boot 内置容器";
+        }
+        list.add(item("Web服务器环境", "webserver", container, "normal", ""));
+
+        String javaVersion = System.getProperty("java.version", "");
+        list.add(item("JDK版本", "java", javaVersion, majorVersion(javaVersion) >= MIN_JDK ? "normal" : "danger",
+                "JDK 版本必须为 " + MIN_JDK + " 及以上"));
+
+        String arch = System.getProperty("os.arch", "");
+        boolean is64 = arch.contains("64");
+        list.add(item("运行位数", "architecture", is64 ? "64位" : "32位",
+                is64 ? "normal" : "warning", "建议使用 64 位运行环境以提升程序性能"));
+
+        Runtime runtime = Runtime.getRuntime();
+        long maxMb = runtime.maxMemory() / 1024 / 1024;
+        list.add(item("JVM最大可用内存", "max_memory", maxMb + " MB",
+                maxMb >= 256 ? "normal" : "warning", "内存过小会导致导出、图片处理等任务失败"));
+
+        list.add(item("程序运行目录", "root_path",
+                Paths.get("").toAbsolutePath().normalize().toString().replace("\\", "/"), "normal", ""));
+        return list;
+    }
+
+    /** 运行环境要求 */
+    private List<Map<String, Object>> environment() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        String javaVersion = System.getProperty("java.version", "");
+        list.add(item("JDK版本", "java_version", String.valueOf(MIN_JDK),
+                majorVersion(javaVersion) >= MIN_JDK ? "normal" : "danger",
+                "JDK 版本必须为 " + MIN_JDK + " 及以上"));
+
+        String charset = System.getProperty("file.encoding", "");
+        list.add(item("字符集编码", "charset", charset,
+                charset != null && charset.toUpperCase().contains("UTF") ? "normal" : "warning",
+                "建议使用 UTF-8 编码,避免中文乱码"));
+
+        list.add(item("系统时区", "timezone", TimeZone.getDefault().getID(),
+                "Asia/Shanghai".equals(TimeZone.getDefault().getID()) ? "normal" : "warning",
+                "建议使用 Asia/Shanghai,与商城数据时间一致"));
+
+        list.add(item("数据库连接", "database", "MySQL", checkDatabase(), "数据库连接异常时系统无法正常运行"));
+        list.add(item("Redis连接", "redis", "Redis", checkRedis(), "Redis 异常时商家后台无法登录"));
+        return list;
+    }
+
+    /** 目录权限监测 */
+    private List<Map<String, Object>> writeable() {
+        String tempPath = tempCachePaths.split(",")[0].trim();
+        Path uploads = Paths.get(uploadPath).toAbsolutePath().normalize();
+        Path downloads = Paths.get(exportPath).toAbsolutePath().normalize();
+        Path temp = Paths.get(tempPath).toAbsolutePath().normalize();
+
+        List<Map<String, Object>> list = new ArrayList<>();
+        list.add(dirItem("文件上传目录", "uploads", uploads, "目录不可写,系统将无法正常上传文件"));
+        list.add(dirItem("文件下载目录", "downloads", downloads, "目录不可写,系统将无法导出订单文件"));
+        list.add(dirItem("临时文件目录", "temp", temp, "目录不可写,系统将无法正常写入临时文件"));
+        return list;
+    }
+
+    private Map<String, Object> dirItem(String name, String key, Path path, String dangerRemark) {
+        boolean exists = Files.isDirectory(path);
+        boolean writable = exists && Files.isWritable(path);
+        return item(name, key, path.toString().replace("\\", "/"),
+                writable ? "normal" : "danger", writable ? "" : dangerRemark);
+    }
+
+    private String checkDatabase() {
+        try (Connection conn = dataSource.getConnection()) {
+            return conn.isValid(2) ? "正常" : "异常";
+        } catch (Exception e) {
+            return "异常";
+        }
+    }
+
+    private String checkRedis() {
+        try {
+            redis.getString("yoshop:science:ping");
+            return "正常";
+        } catch (Exception e) {
+            return "异常";
+        }
+    }
+
+    /** 取 JDK 主版本号:21.0.2 → 21;1.8.0_302 → 8 */
+    private int majorVersion(String version) {
+        if (version == null || version.isBlank()) {
+            return 0;
+        }
+        String v = version.startsWith("1.") ? version.substring(2) : version;
+        int end = 0;
+        while (end < v.length() && Character.isDigit(v.charAt(end))) {
+            end++;
+        }
+        try {
+            return end == 0 ? 0 : Integer.parseInt(v.substring(0, end));
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    private Map<String, Object> item(String name, String key, String value, String status, String remark) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("name", name);
+        m.put("key", key);
+        m.put("value", value);
+        m.put("status", status);
+        m.put("remark", remark);
+        return m;
+    }
+}

+ 206 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminStoreService.java

@@ -0,0 +1,206 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.common.entity.Store;
+import cn.yoshop.common.entity.StoreUser;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.StoreMapper;
+import cn.yoshop.common.mapper.StoreUserMapper;
+import cn.yoshop.common.page.PageVO;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.common.util.PasswordUtil;
+import cn.yoshop.common.util.TimeUtil;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 商城(商户)管理,对齐 PHP app/admin/model/Store + app/admin/controller/Store。
+ *
+ * <p>注意 PHP 的两个动作名字是反的,这里照搬以免前端行为变化:
+ * <ul>
+ *   <li>{@code recovery} → 移入回收站(is_recycle = 1)</li>
+ *   <li>{@code move} → 移出回收站(is_recycle = 0)</li>
+ * </ul>
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminStoreService {
+
+    private static final int PAGE_SIZE = 15;
+
+    private final StoreMapper storeMapper;
+    private final StoreUserMapper storeUserMapper;
+
+    /**
+     * 商城列表 / 回收站列表,对齐 PHP Store::getList:
+     * with('superUser') + where is_recycle + is_delete=0 + order sort asc, create_time desc + 每页 15。
+     */
+    public PageVO<Map<String, Object>> getList(Map<String, Object> params, boolean recycle) {
+        int page = Params.intVal(params, "page", 1);
+        IPage<Store> result = storeMapper.selectPage(new Page<>(page, PAGE_SIZE),
+                Wrappers.<Store>lambdaQuery()
+                        .eq(Store::getIsRecycle, recycle ? 1 : 0)
+                        .orderByAsc(Store::getSort)
+                        .orderByDesc(Store::getCreateTime));
+
+        List<Store> stores = result.getRecords();
+        Map<Integer, StoreUser> superUsers = new LinkedHashMap<>();
+        if (!stores.isEmpty()) {
+            List<Integer> storeIds = stores.stream().map(Store::getStoreId).toList();
+            superUsers = storeUserMapper.selectList(Wrappers.<StoreUser>lambdaQuery()
+                            .in(StoreUser::getStoreId, storeIds)
+                            .eq(StoreUser::getIsSuper, 1))
+                    .stream().collect(Collectors.toMap(StoreUser::getStoreId, u -> u, (a, b) -> a));
+        }
+
+        List<Map<String, Object>> list = new ArrayList<>(stores.size());
+        for (Store store : stores) {
+            list.add(toListItem(store, superUsers.get(store.getStoreId())));
+        }
+
+        PageVO<Map<String, Object>> vo = new PageVO<>();
+        vo.setTotal(result.getTotal());
+        vo.setPerPage(PAGE_SIZE);
+        long lastPage = result.getPages();
+        long current = result.getCurrent();
+        vo.setCurrentPage(current > lastPage ? (lastPage > 0 ? lastPage : 1) : current);
+        vo.setLastPage(lastPage);
+        vo.setData(list);
+        return vo;
+    }
+
+    /** 新增商城:免费版固定拒绝,文案与 PHP 一致 */
+    public void add() {
+        throw BizException.silent("很抱歉,免费版暂不支持多开商城");
+    }
+
+    /** 移入 / 移出回收站 */
+    public void recycle(int storeId, boolean isRecycle) {
+        Store store = requireStore(storeId);
+        Store update = new Store();
+        update.setStoreId(store.getStoreId());
+        update.setIsRecycle(isRecycle ? 1 : 0);
+        update.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+        storeMapper.updateById(update);
+    }
+
+    /**
+     * 修改商家账户,对齐 PHP Store::edit:
+     * 校验确认密码、商家用户名唯一,事务内更新 store(remark/sort)与 super 商家用户(user_name/password)。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void edit(int storeId, Map<String, Object> form) {
+        Store store = requireStore(storeId);
+        StoreUser superUser = storeUserMapper.selectOne(Wrappers.<StoreUser>lambdaQuery()
+                .eq(StoreUser::getStoreId, storeId)
+                .eq(StoreUser::getIsSuper, 1)
+                .last("LIMIT 1"));
+
+        String userName = Params.strOrNull(form, "userName");
+        String password = Params.strOrNull(form, "password");
+        String passwordConfirm = Params.strOrNull(form, "passwordConfirm");
+        if (passwordConfirm == null) {
+            passwordConfirm = Params.strOrNull(form, "password_confirm");
+        }
+
+        if (!isBlank(password) && !password.equals(passwordConfirm)) {
+            throw BizException.silent("确认密码不正确");
+        }
+
+        if (!isBlank(userName)) {
+            String name = userName.trim();
+            boolean changed = superUser == null || !name.equals(superUser.getUserName());
+            if (changed && existsUserName(name)) {
+                throw BizException.silent("商家用户名已存在");
+            }
+        }
+
+        // 1) 商城记录:备注 / 排序
+        Store storeUpdate = new Store();
+        storeUpdate.setStoreId(store.getStoreId());
+        String remark = Params.strOrNull(form, "remark");
+        Integer sort = Params.intOrNull(form, "sort");
+        storeUpdate.setRemark(remark != null ? remark : store.getRemark());
+        storeUpdate.setSort(sort != null ? sort : store.getSort());
+        storeUpdate.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+        storeMapper.updateById(storeUpdate);
+
+        // 2) 超级管理员账号
+        if (superUser != null) {
+            StoreUser userUpdate = new StoreUser();
+            userUpdate.setStoreUserId(superUser.getStoreUserId());
+            boolean dirty = false;
+            if (!isBlank(userName)) {
+                userUpdate.setUserName(userName.trim());
+                dirty = true;
+            }
+            if (!isBlank(password)) {
+                userUpdate.setPassword(PasswordUtil.hash(password));
+                dirty = true;
+            }
+            if (dirty) {
+                userUpdate.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+                storeUserMapper.updateById(userUpdate);
+            }
+        }
+    }
+
+    private Store requireStore(int storeId) {
+        Store store = storeMapper.selectById(storeId);
+        if (store == null) {
+            throw BizException.silent("很抱歉,商城不存在");
+        }
+        return store;
+    }
+
+    /** 对齐 PHP StoreUser::checkExist:is_delete=0 下同名即视为已存在 */
+    private boolean existsUserName(String userName) {
+        return storeUserMapper.selectCount(Wrappers.<StoreUser>lambdaQuery()
+                .eq(StoreUser::getUserName, userName)) > 0;
+    }
+
+    /** 列表项字段集对齐 PHP 商家模型(含 superUser,不含 logoImage 关联) */
+    private Map<String, Object> toListItem(Store store, StoreUser superUser) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("storeId", store.getStoreId());
+        item.put("storeName", store.getStoreName());
+        item.put("describe", store.getDescribe());
+        item.put("logoImageId", store.getLogoImageId());
+        item.put("remark", store.getRemark());
+        item.put("customDomain", store.getCustomDomain());
+        item.put("sort", store.getSort());
+        item.put("isRecycle", store.getIsRecycle());
+        item.put("isDelete", store.getIsDelete());
+        item.put("createTime", TimeUtil.format(store.getCreateTime()));
+        item.put("updateTime", TimeUtil.format(store.getUpdateTime()));
+        item.put("superUser", superUser == null ? null : userItem(superUser));
+        return item;
+    }
+
+    private Map<String, Object> userItem(StoreUser user) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("storeUserId", user.getStoreUserId());
+        m.put("userName", user.getUserName());
+        m.put("realName", user.getRealName());
+        m.put("isSuper", user.getIsSuper());
+        m.put("isDelete", user.getIsDelete());
+        m.put("sort", user.getSort());
+        m.put("storeId", user.getStoreId());
+        m.put("createTime", TimeUtil.format(user.getCreateTime()));
+        m.put("updateTime", TimeUtil.format(user.getUpdateTime()));
+        return m;
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.isBlank();
+    }
+}

+ 43 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminTimerService.java

@@ -0,0 +1,43 @@
+package cn.yoshop.admin.service;
+
+import cn.yoshop.common.entity.SystemProcess;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.SystemProcessMapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+/**
+ * 定时任务探活,对齐 PHP app/admin/controller/setting/Timer::test:
+ * 先等 3 秒(让正在跑的任务有机会刷新时间),再看 {@code yoshop_system_process} 中
+ * key=timer 的 last_working_time 是否在 10 秒内,以此判断定时任务是否开启。
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminTimerService {
+
+    /** PHP sleep(3) */
+    private static final long WAIT_MILLIS = 3000L;
+    /** 允许的时间差(秒) */
+    private static final long ALIVE_SECONDS = 10L;
+
+    private final SystemProcessMapper systemProcessMapper;
+
+    /** 探活通过返回提示语,未开启抛业务异常(对齐 PHP renderError) */
+    public String test() {
+        try {
+            Thread.sleep(WAIT_MILLIS);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        SystemProcess process = systemProcessMapper.selectOne(Wrappers.<SystemProcess>lambdaQuery()
+                .eq(SystemProcess::getKey, "timer")
+                .last("LIMIT 1"));
+        long last = process == null || process.getLastWorkingTime() == null ? 0 : process.getLastWorkingTime();
+        long now = System.currentTimeMillis() / 1000;
+        if (now - last <= ALIVE_SECONDS) {
+            return "恭喜您,定时任务已开启";
+        }
+        throw BizException.silent("很抱歉,定时任务未开启");
+    }
+}

+ 145 - 0
yoshop-java/src/main/java/cn/yoshop/admin/service/AdminUserService.java

@@ -0,0 +1,145 @@
+package cn.yoshop.admin.service;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.yoshop.common.auth.AdminTokenStore;
+import cn.yoshop.common.entity.AdminUser;
+import cn.yoshop.common.exception.BizException;
+import cn.yoshop.common.mapper.AdminUserMapper;
+import cn.yoshop.common.util.Params;
+import cn.yoshop.common.util.PasswordUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.bcrypt.BCrypt;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 平台超管账户,对齐 PHP:
+ * app/admin/model/admin/User(login / renew)+ app/admin/service/admin/User(缓存读写)。
+ */
+@Service
+@RequiredArgsConstructor
+public class AdminUserService {
+
+    private final AdminUserMapper adminUserMapper;
+    private final AdminTokenStore tokenStore;
+
+    /**
+     * 账号密码登录:口令 bcrypt 校验,登录态按 token 写 Redis(key = token)。
+     *
+     * @return {userId, token}
+     */
+    @Transactional(readOnly = true)
+    public Map<String, Object> login(String username, String password) {
+        String name = username == null ? "" : username.trim();
+        if (name.isEmpty() || password == null || password.isEmpty()) {
+            throw BizException.silent("登录失败, 用户名或密码错误");
+        }
+        AdminUser user = adminUserMapper.selectOne(Wrappers.<AdminUser>lambdaQuery()
+                .eq(AdminUser::getUserName, name)
+                .last("LIMIT 1"));
+        if (user == null || !verifyPassword(password, user.getPassword())) {
+            throw BizException.silent("登录失败, 用户名或密码错误");
+        }
+
+        String token = makeToken(user.getAdminUserId());
+        try {
+            tokenStore.save(token, user);
+        } catch (Exception e) {
+            throw new BizException("登录失败, 登录态写入失败: " + e.getMessage());
+        }
+
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("userId", user.getAdminUserId());
+        data.put("token", token);
+        return data;
+    }
+
+    /** 退出登录:删除 Redis 登录态 */
+    public void logout(String token) {
+        tokenStore.delete(token);
+    }
+
+    /** 当前登录管理员(拦截器已按 token 从 Redis 取出,无需回查数据库) */
+    public AdminUser getLoginUser(String token) {
+        AdminUser user = tokenStore.getUser(token);
+        if (user == null) {
+            throw new BizException("请先登录后再访问", 401);
+        }
+        return user;
+    }
+
+    /**
+     * 修改当前管理员的账号/密码(PHP AdminUser::renew)。
+     *
+     * <p>差异说明:PHP 在不传 password 时会把口令哈希成空串(等于把账号锁死),
+     * Java 端按「不传或空串 = 不修改密码」处理,更安全。
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void renew(String token, Map<String, Object> form) {
+        AdminUser current = getLoginUser(token);
+        String userName = Params.strOrNull(form, "userName");
+        String password = Params.strOrNull(form, "password");
+        String passwordConfirm = Params.strOrNull(form, "passwordConfirm");
+        if (passwordConfirm == null) {
+            passwordConfirm = Params.strOrNull(form, "password_confirm");
+        }
+        if (!isBlank(password) && !password.equals(passwordConfirm)) {
+            throw BizException.silent("确认密码不正确");
+        }
+
+        AdminUser update = new AdminUser();
+        update.setAdminUserId(current.getAdminUserId());
+        boolean changed = false;
+        if (!isBlank(userName)) {
+            update.setUserName(userName.trim());
+            changed = true;
+        }
+        if (!isBlank(password)) {
+            update.setPassword(PasswordUtil.hash(password));
+            changed = true;
+        }
+        if (changed) {
+            update.setUpdateTime((int) (System.currentTimeMillis() / 1000));
+            adminUserMapper.updateById(update);
+        }
+
+        // 刷新登录态,沿用剩余有效期,不因改资料延长登录时长
+        AdminUser fresh = adminUserMapper.selectById(current.getAdminUserId());
+        if (fresh == null) {
+            return;
+        }
+        long ttl = tokenStore.remainingTtlSeconds(token);
+        if (ttl > 0) {
+            tokenStore.save(token, fresh, ttl);
+        } else {
+            tokenStore.save(token, fresh);
+        }
+    }
+
+    /** PHP password_verify 的 Java 实现 */
+    private boolean verifyPassword(String raw, String hashed) {
+        if (hashed == null || hashed.length() < 4) {
+            return false;
+        }
+        try {
+            return BCrypt.checkpw(raw, hashed);
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
+    /** token 规则同 PHP:md5(微秒时间戳_用户ID_guid_salt),32 位小写 hex */
+    private String makeToken(Integer adminUserId) {
+        String raw = System.nanoTime() + "_" + adminUserId + "_" + IdUtil.fastSimpleUUID() + "_admin_user_salt_";
+        return DigestUtil.md5Hex(raw);
+    }
+
+    private static boolean isBlank(String s) {
+        return s == null || s.isBlank();
+    }
+}

+ 98 - 0
yoshop-java/src/main/java/cn/yoshop/common/auth/AdminAuthInterceptor.java

@@ -0,0 +1,98 @@
+package cn.yoshop.common.auth;
+
+import cn.yoshop.common.api.Status;
+import cn.yoshop.common.entity.AdminUser;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 平台超管后台登录拦截器,对齐 PHP app/admin/controller/Controller::initialize():
+ *
+ * <ul>
+ *   <li>token 取自请求头 Access-Token</li>
+ *   <li>白名单 routeUri:passport/login(PHP $allowAllAction 只有这一条)</li>
+ *   <li>未登录响应:{"status":401,"message":"请先登录后再访问","data":{"isPrompt":true}},HTTP 恒 200</li>
+ *   <li>登录管理员按 token 从 Redis 取({@link AdminTokenStore}),放入 request attribute "adminUser"</li>
+ * </ul>
+ *
+ * <p>与商家端 {@link StoreAuthInterceptor} 各自独立:路径前缀不同、token 前缀不同,
+ * 拿商家 token 访问 /admin/** 会按未登录处理。
+ */
+@Component
+public class AdminAuthInterceptor implements HandlerInterceptor {
+
+    /** 与 PHP 端 $allowAllAction 一致 */
+    private static final Set<String> ALLOW_ALL = Set.of("passport/login");
+
+    /** 未登录响应 data,对齐 PHP throwError 的 {"isPrompt": true} */
+    private static final Map<String, Object> ERROR_DATA = Map.of("isPrompt", true);
+
+    private final AdminTokenStore tokenStore;
+    private final ObjectMapper objectMapper;
+
+    @Value("${yoshop.auth.mode:redis}")
+    private String mode;
+
+    public AdminAuthInterceptor(AdminTokenStore tokenStore, ObjectMapper objectMapper) {
+        this.tokenStore = tokenStore;
+        this.objectMapper = objectMapper;
+    }
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        if ("off".equalsIgnoreCase(mode)) {
+            return true;
+        }
+
+        String routeUri = routeUri(request);
+        if (ALLOW_ALL.contains(routeUri)) {
+            return true;
+        }
+
+        AdminUser adminUser = tokenStore.getUser(request.getHeader("Access-Token"));
+        if (adminUser == null) {
+            writeUnauthorized(response);
+            return false;
+        }
+
+        request.setAttribute("adminUser", adminUser);
+        return true;
+    }
+
+    /**
+     * 复现 PHP getRouteinfo():控制器 URL 段 + 方法名。
+     * /admin/passport/login -> passport/login;/admin/store.menu/index -> store.menu/index
+     */
+    private String routeUri(HttpServletRequest request) {
+        String path = request.getRequestURI();
+        String context = request.getContextPath();
+        if (context != null && !context.isEmpty() && path.startsWith(context)) {
+            path = path.substring(context.length());
+        }
+        if (path.startsWith("/admin/")) {
+            path = path.substring("/admin".length());
+        }
+        return path.startsWith("/") ? path.substring(1) : path;
+    }
+
+    private void writeUnauthorized(HttpServletResponse response) throws Exception {
+        response.setStatus(HttpServletResponse.SC_OK); // 契约:HTTP 恒 200
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", Status.NOT_LOGGED);
+        body.put("message", "请先登录后再访问");
+        body.put("data", ERROR_DATA);
+        response.getWriter().write(objectMapper.writeValueAsString(body));
+    }
+}

+ 148 - 0
yoshop-java/src/main/java/cn/yoshop/common/auth/AdminTokenStore.java

@@ -0,0 +1,148 @@
+package cn.yoshop.common.auth;
+
+import cn.yoshop.common.entity.AdminUser;
+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;
+
+/**
+ * 平台超管(admin 端)登录态存储,口径与商家端 {@link RedisTokenStore} 一致:
+ *
+ * <ul>
+ *   <li>key = {@code ${yoshop.auth.admin-token-prefix} + token},默认 {@code yoshop:admin:token:{token}}</li>
+ *   <li>value = {@link AdminUser} 的 JSON(camelCase,不含 password)</li>
+ *   <li>有效期默认 7 天({@code ${yoshop.auth.admin-token-ttl}})</li>
+ * </ul>
+ *
+ * <p>前缀与商家端分开,两套身份不会互相串号:拿商家 token 访问 /admin/** 取不到管理员。
+ */
+@Component
+public class AdminTokenStore {
+
+    private static final Logger log = LoggerFactory.getLogger(AdminTokenStore.class);
+
+    private final RedisUtils redis;
+
+    /** 登录态 JSON 的读写用自己的 mapper,避免受全局 Jackson 配置影响 */
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Value("${yoshop.auth.admin-token-prefix:yoshop:admin:token:}")
+    private String prefix;
+
+    @Value("${yoshop.auth.admin-token-ttl:604800}")
+    private long defaultTtlSeconds;
+
+    public AdminTokenStore(RedisUtils redis) {
+        this.redis = redis;
+    }
+
+    /** 按 token 取登录管理员;token 为空、键不存在或内容损坏一律返回 null */
+    public AdminUser getUser(String token) {
+        if (token == null || token.isBlank()) {
+            return null;
+        }
+        try {
+            String json = redis.getString(key(token));
+            if (json == null) {
+                return null;
+            }
+            Map<String, Object> value = objectMapper.readValue(
+                    json, new TypeReference<LinkedHashMap<String, Object>>() {
+                    });
+            AdminUser user = fromCacheMap(value);
+            return user.getAdminUserId() == null ? null : user;
+        } catch (Exception e) {
+            log.warn("读取超管登录态失败 token={} : {}", token, e.getMessage());
+            return null;
+        }
+    }
+
+    public void save(String token, AdminUser user) {
+        save(token, user, defaultTtlSeconds);
+    }
+
+    public void save(String token, AdminUser user, long ttlSeconds) {
+        if (token == null || token.isBlank() || user == null) {
+            return;
+        }
+        String json;
+        try {
+            json = objectMapper.writeValueAsString(toCacheMap(user));
+        } catch (Exception e) {
+            throw new IllegalStateException("登录态序列化失败: " + e.getMessage(), e);
+        }
+        if (ttlSeconds > 0) {
+            redis.setString(key(token), json, ttlSeconds);
+        } else {
+            redis.setString(key(token), json);
+        }
+    }
+
+    public void delete(String token) {
+        if (token == null || token.isBlank()) {
+            return;
+        }
+        try {
+            redis.deleteString(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;
+    }
+
+    private Map<String, Object> toCacheMap(AdminUser user) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("adminUserId", user.getAdminUserId());
+        m.put("userName", user.getUserName());
+        m.put("createTime", user.getCreateTime());
+        m.put("updateTime", user.getUpdateTime());
+        return m;
+    }
+
+    private AdminUser fromCacheMap(Map<String, Object> map) {
+        AdminUser user = new AdminUser();
+        user.setAdminUserId(toInt(map.get("adminUserId")));
+        user.setUserName(map.get("userName") == null ? null : String.valueOf(map.get("userName")));
+        user.setCreateTime(toInt(map.get("createTime")));
+        user.setUpdateTime(toInt(map.get("updateTime")));
+        return user;
+    }
+
+    private static Integer toInt(Object value) {
+        if (value instanceof Number n) {
+            return n.intValue();
+        }
+        if (value instanceof String s && !s.isBlank()) {
+            try {
+                return Integer.valueOf(s.trim());
+            } catch (NumberFormatException ignored) {
+                return null;
+            }
+        }
+        return null;
+    }
+}

+ 45 - 0
yoshop-java/src/main/java/cn/yoshop/common/entity/AdminUser.java

@@ -0,0 +1,45 @@
+package cn.yoshop.common.entity;
+
+import cn.yoshop.common.util.TimeUtil;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 平台超管用户,对应 yoshop_admin_user。
+ *
+ * <p>对齐 PHP app/admin/model/admin/User($hidden = ['password']):
+ * 出参不含 password,create_time / update_time 输出日期字符串。
+ */
+@Data
+@TableName("yoshop_admin_user")
+public class AdminUser {
+
+    @TableId(value = "admin_user_id", type = IdType.AUTO)
+    private Integer adminUserId;
+
+    private String userName;
+
+    /** bcrypt 口令,不输出到接口、也不写进 Redis 登录态 */
+    @JsonIgnore
+    private String password;
+
+    @JsonIgnore
+    private Integer createTime;
+
+    @JsonIgnore
+    private Integer updateTime;
+
+    @JsonProperty("createTime")
+    public String getCreateTimeText() {
+        return TimeUtil.format(createTime);
+    }
+
+    @JsonProperty("updateTime")
+    public String getUpdateTimeText() {
+        return TimeUtil.format(updateTime);
+    }
+}

+ 57 - 0
yoshop-java/src/main/java/cn/yoshop/common/entity/StoreApi.java

@@ -0,0 +1,57 @@
+package cn.yoshop.common.entity;
+
+import cn.yoshop.common.util.TimeUtil;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 商家后台 API 权限点,对应 yoshop_store_api。
+ *
+ * <p>平台后台(admin)用于维护权限点树,对齐 PHP app/admin/model/store/Api:
+ * 列表按 parent_id 组树,子节点放在 children(无子节点时不输出该键)。
+ */
+@Data
+@TableName("yoshop_store_api")
+public class StoreApi {
+
+    @TableId(value = "api_id", type = IdType.AUTO)
+    private Integer apiId;
+
+    private String name;
+
+    /** 权限 url,形如 goods/list 或 store.user/info */
+    private String url;
+
+    private Integer parentId;
+
+    private Integer sort;
+
+    @JsonIgnore
+    private Integer createTime;
+
+    @JsonIgnore
+    private Integer updateTime;
+
+    @JsonProperty("createTime")
+    public String getCreateTimeText() {
+        return TimeUtil.format(createTime);
+    }
+
+    @JsonProperty("updateTime")
+    public String getUpdateTimeText() {
+        return TimeUtil.format(updateTime);
+    }
+
+    /** 子权限(树状列表用) */
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    @TableField(exist = false)
+    private List<StoreApi> children;
+}

+ 25 - 0
yoshop-java/src/main/java/cn/yoshop/common/entity/SystemProcess.java

@@ -0,0 +1,25 @@
+package cn.yoshop.common.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/**
+ * 系统进程记录,对应 yoshop_system_process(key 是 MySQL 保留字,需反引号)。
+ *
+ * <p>定时任务探活(/admin/setting.timer/test)读取 key=timer 的 last_working_time。
+ */
+@Data
+@TableName("yoshop_system_process")
+public class SystemProcess {
+
+    @TableId("`key`")
+    private String key;
+
+    private String name;
+
+    /** 最后运行时间(Unix 秒) */
+    @TableField("last_working_time")
+    private Integer lastWorkingTime;
+}

+ 9 - 0
yoshop-java/src/main/java/cn/yoshop/common/mapper/AdminUserMapper.java

@@ -0,0 +1,9 @@
+package cn.yoshop.common.mapper;
+
+import cn.yoshop.common.entity.AdminUser;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AdminUserMapper extends BaseMapper<AdminUser> {
+}

+ 9 - 0
yoshop-java/src/main/java/cn/yoshop/common/mapper/StoreApiMapper.java

@@ -0,0 +1,9 @@
+package cn.yoshop.common.mapper;
+
+import cn.yoshop.common.entity.StoreApi;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StoreApiMapper extends BaseMapper<StoreApi> {
+}

+ 9 - 0
yoshop-java/src/main/java/cn/yoshop/common/mapper/SystemProcessMapper.java

@@ -0,0 +1,9 @@
+package cn.yoshop.common.mapper;
+
+import cn.yoshop.common.entity.SystemProcess;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SystemProcessMapper extends BaseMapper<SystemProcess> {
+}

+ 9 - 3
yoshop-java/src/main/java/cn/yoshop/config/WebMvcConfig.java

@@ -1,5 +1,6 @@
 package cn.yoshop.config;
 
+import cn.yoshop.common.auth.AdminAuthInterceptor;
 import cn.yoshop.common.auth.StoreAuthInterceptor;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Configuration;
@@ -18,6 +19,7 @@ import java.nio.file.Paths;
 public class WebMvcConfig implements WebMvcConfigurer {
 
     private final StoreAuthInterceptor storeAuthInterceptor;
+    private final AdminAuthInterceptor adminAuthInterceptor;
 
     @Value("${yoshop.upload-path:runtime/uploads}")
     private String uploadPath;
@@ -26,8 +28,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
     @Value("${yoshop.export-path:runtime/downloads}")
     private String exportPath;
 
-    public WebMvcConfig(StoreAuthInterceptor storeAuthInterceptor) {
+    public WebMvcConfig(StoreAuthInterceptor storeAuthInterceptor,
+                        AdminAuthInterceptor adminAuthInterceptor) {
         this.storeAuthInterceptor = storeAuthInterceptor;
+        this.adminAuthInterceptor = adminAuthInterceptor;
     }
 
     /**
@@ -54,9 +58,11 @@ public class WebMvcConfig implements WebMvcConfigurer {
 
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
-        // 商家后台 / 平台后台统一走登录拦截(白名单在拦截器内,对齐 PHP $allowAllAction)
+        // 商家后台 /store/** 与平台后台 /admin/** 是两套身份(表、token 前缀都不同),各用各的拦截器
         registry.addInterceptor(storeAuthInterceptor)
-                .addPathPatterns("/store/**", "/admin/**");
+                .addPathPatterns("/store/**");
+        registry.addInterceptor(adminAuthInterceptor)
+                .addPathPatterns("/admin/**");
         // 客户端 /api/** 使用用户 token,体系不同,后续单独做
     }
 }

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

@@ -53,6 +53,9 @@ yoshop:
   auth:
     # off = 开发/联调模式(放行所有请求);redis = 真实校验(登录态存 Redis)
     mode: "redis"
-    # 登录态在 Redis 中的 key 前缀与有效期(秒),默认 7 天
+    # 商家端登录态:key 前缀与有效期(秒),默认 7 天
     token-prefix: "yoshop:store:token:"
     token-ttl: 604800
+    # 平台后台(admin)登录态,与商家端分开存,互不串号
+    admin-token-prefix: "yoshop:admin:token:"
+    admin-token-ttl: 604800

+ 84 - 0
接口实现进度-第9批-admin.md

@@ -0,0 +1,84 @@
+# 第 9 批接口实现进度:平台后台 admin(24 个 → 落地 23 个)
+
+> 时间:2026-10-18
+> 范围:`app/admin` 模块全部接口(API清单.xlsx「admin-管理端」sheet 共 24 行)
+> 验证:`tools/test_admin_api.py` **45 项断言全通过**(认证、登录态、权限点/菜单 CRUD、商城管理、运维接口、越权与登出)
+
+## 一、本批接口
+
+| 分组 | 接口 | 说明 |
+|---|---|---|
+| 认证 | `/admin/passport/login` | 账号密码登录,返回 `{userId, token}`,message「登录成功」 |
+| | `/admin/passport/logout` | 删除 Redis 登录态 |
+| 商城管理 | `/admin/store/index` | 分页 15,`sort asc + create_time desc`,带 `superUser` |
+| | `/admin/store/add` | 免费版固定拒绝:「很抱歉,免费版暂不支持多开商城」 |
+| | `/admin/store/recycle` | 回收站列表(`is_recycle=1`) |
+| | `/admin/store/recovery` | **移入**回收站(`is_recycle=1`,PHP 命名如此,照搬) |
+| | `/admin/store/edit` | 改备注/排序 + 超管账号密码;校验确认密码、用户名唯一 |
+| | `/admin/store/move` | **移出**回收站(`is_recycle=0`) |
+| 平台管理员 | `/admin/admin.user/detail` | 当前登录管理员(取 Redis 缓存,不回查库) |
+| | `/admin/admin.user/renew` | 改账号/密码,刷新缓存并沿用剩余 TTL |
+| 权限点 | `/admin/store.api/index` | 树状(`parentId` → `children`),`sort asc + create_time asc` |
+| | `/admin/store.api/add` | 新增(name/url/parentId/sort) |
+| | `/admin/store.api/edit` | 改;上级不得选到自己的子级(防环) |
+| | `/admin/store.api/delete` | 有子级拒绝;先清 `store_menu_api` 再删 |
+| 后台菜单 | `/admin/store.menu/index` | 树状 + `apiIds`(未绑定不输出该键),隐藏 `menuApi` |
+| | `/admin/store.menu/info` | 详情(不含 `apiIds` / `children`) |
+| | `/admin/store.menu/add` | 新增(type/name/path/isPage/moduleKey/actionMark/parentId/sort) |
+| | `/admin/store.menu/edit` | 改;上级不得选到自己的子级(防环) |
+| | `/admin/store.menu/set_apis` | 绑定 API:空数组拒绝;先删后插(事务) |
+| | `/admin/store.menu/delete` | 有子级拒绝;先清 `store_menu_api` 再删 |
+| 运维 | `/admin/setting.cache/clear` | `item` 含 `data` 清缓存(`isForce` 连登录态一起清)、含 `temp` 清临时目录 |
+| | `/admin/setting.science/info` | 环境检测,三段结构 `{server, phpinfo, writeable}` |
+| | `/admin/setting.timer/test` | 等待 3 秒后比 `system_process.last_working_time` 是否在 10 秒内 |
+| 未实现 | `/admin/controller/initialize` | PHP 基类钩子(登录校验),不是业务接口;Java 由 `AdminAuthInterceptor` 承担 |
+
+## 二、新增/改动文件
+
+**实体**:`AdminUser`(yoshop_admin_user)、`StoreApi`(yoshop_store_api)、`SystemProcess`(yoshop_system_process,
+`key` 列反引号映射)、`admin/entity/AdminMenu`(yoshop_store_menu 的平台侧视图:输出 `sort`/`actionMark`/时间字段与
+`apiIds`,隐藏 `menuApi`,与商家端 `StoreMenu` 字段集不同故单独建模)。
+
+**Mapper**:`AdminUserMapper`、`StoreApiMapper`、`SystemProcessMapper`、`admin/mapper/AdminMenuMapper`(新增)。
+
+**认证**:`common/auth/AdminTokenStore`(Redis,`yoshop:admin:token:{token}`,值=管理员 JSON、不含 password,TTL 7 天)、
+`common/auth/AdminAuthInterceptor`(白名单仅 `passport/login`,未登录 401「请先登录后再访问」)。
+
+**服务**:`admin/service/AdminUserService`、`AdminStoreService`、`AdminApiService`、`AdminMenuService`、
+`AdminCacheService`、`AdminScienceService`、`AdminTimerService`。
+
+**控制器**:`AdminPassportController`、`AdminStoreController`、`AdminUserController`、`AdminStoreApiController`、
+`AdminStoreMenuController`、`AdminCacheController`、`AdminScienceController`、`AdminTimerController`。
+
+**配置**:`WebMvcConfig` 拆分拦截路径(`/store/**` → StoreAuthInterceptor、`/admin/**` → AdminAuthInterceptor,
+此前 `/admin/**` 误挂商家端拦截器);`application.yml` 新增 `yoshop.auth.admin-token-prefix` / `admin-token-ttl`
+与 `yoshop.temp-cache-paths`。
+
+## 三、与 PHP 的差异(有意为之)
+
+1. **登录态**:PHP 用 File 缓存,Java 用 Redis,商家端与平台端前缀分开,互不串号(拿商家 token 访问 `/admin/**` 返回 401)。
+2. **admin.user/detail**:PHP 回查数据库,Java 直接读登录态缓存;改库不会即时反映,需 `renew` 或重新登录。
+3. **admin.user/renew 空密码**:PHP 会把口令哈希成空串(等于锁死账号),Java 按「空 = 不修改」处理。
+4. **store/edit 缺字段**:PHP 未传 remark/sort/user_name 会写成空值,Java 取「未传 = 保持原值」。
+5. **setting.science/info**:不再检测 PHP 版本与扩展,改为 JDK/容器/内存/编码/时区/数据库/Redis/目录可写;
+   三段分组键与 `{name,key,value,status,remark}` 形状保持不变。
+6. **setting.cache/clear**:PHP 用 tag 清缓存,Java 约定业务缓存前缀 `yoshop:cache:`;`isForce` 时清 `yoshop:*`
+   (会连登录态一起清,与 PHP `cache->clear()` 语义一致)。`temp` 只删 `yoshop.temp-cache-paths` 内的目录,
+   并在删除前校验目录必须位于工作目录下。
+7. **出参命名**:延续 2026-10-17 起的驼峰口径(`storeId`/`superUser`/`apiIds`/`parentId`…)。
+
+## 四、验证
+
+`tools/test_admin_api.py`(45 项,全通过):
+
+- 登录:token 32 位、Redis 值顶层为管理员字段且无 password、TTL=604800
+- 越权:无 token / 错误 token → 401;商家 token 访问 `/admin/**` → 401
+- admin.user:detail 不含 password;renew 空密码不改口令;两次密码不一致 → 「确认密码不正确」
+- 商城:index 分页结构(perPage=15)与 `superUser`;add 固定拒绝;edit 原值回写;recovery/move 回收站往返(DB 断言)
+- 权限点:树结构、增改删、子级保护、防环
+- 菜单:树 + `apiIds`、详情无 `apiIds`、增改、`set_apis` 空值拒绝与绑定(DB 断言)、删菜单清关联
+- 运维:cache/clear 非强制模式不清登录态;science 三段结构与检测项形状;timer 结果与 `system_process` 一致
+- 登出:Redis key 删除、旧 token 401
+
+测试数据:临时权限点/菜单全部删除,商城 `remark`/`sort`/`is_recycle` 与超管用户名还原;
+admin 口令临时改为测试值后已还原原始 bcrypt 哈希。

+ 16 - 0
联调说明.md

@@ -87,3 +87,19 @@ NODE_OPTIONS=--openssl-legacy-provider ./node_modules/.bin/vue-cli-service serve
 5. **⚠️ 本地库与线上库不是强一致**。实测:PHP 写入的数据本地能读到,但 Java 写入的数据线上 PHP 读不到(新建商品后 PHP 报「商品信息不存在」)。
    说明 `192.168.1.177/shop` 与线上 PHP 库之间是 PHP → 本地的单向同步。
    影响:写接口只能用「库内断言 + Java 读回」验证;**上线前必须把 `application.yml` 的数据源指向线上主库**,否则后台改的数据在线上不生效。
+
+## 六、平台后台(admin)联调
+
+第 9 批已完成 `app/admin` 模块全部业务接口(23 个,`controller/initialize` 为 PHP 基类钩子不实现),
+详见 `接口实现进度-第9批-admin.md`。
+
+- 登录:`POST /admin/passport/login`,body `{"username":"admin","password":"***"}` → `{userId, token}`
+- 登录态:Redis `yoshop:admin:token:{token}`,值=管理员 JSON(不含 password),TTL 7 天
+- 与商家端完全隔离:`/store/**` 走 `StoreAuthInterceptor`,`/admin/**` 走 `AdminAuthInterceptor`;
+  拿商家 token 访问 `/admin/**` 一律 401
+- 主要接口:`/admin/store/{index,recycle,recovery,edit,move,add}`、`/admin/store.api/{index,add,edit,delete}`、
+  `/admin/store.menu/{index,info,add,edit,set_apis,delete}`、`/admin/admin.user/{detail,renew}`、
+  `/admin/setting.{cache, science, timer}/...`
+- 注意:`/admin/store/recovery` 是「移入回收站」、`/admin/store/move` 是「移出回收站」(PHP 原有命名)
+- 一键验证:`python tools/test_admin_api.py`(需服务在 18080,并假定 admin 口令为 `123456`;
+  测试时是临时改库内的 bcrypt 哈希跑完再还原,平时请勿依赖该口令)