Browse Source

feat:加入登录验证

Jerry Liang 1 year ago
parent
commit
139df24c4e
48 changed files with 2501 additions and 419 deletions
  1. 1 0
      package.json
  2. 253 235
      pnpm-lock.yaml
  3. 326 0
      service/Untitled-1
  4. 234 21
      service/cache/cache.go
  5. 169 0
      service/cache/cache_aside.go
  6. 50 0
      service/cache/options.go
  7. 72 0
      service/cache/redisx/client.go
  8. 17 0
      service/cache/redisx/hashx.go
  9. 34 0
      service/cache/redisx/helper.go
  10. 134 0
      service/cache/redisx/jsonx.go
  11. 17 0
      service/cache/redisx/listx.go
  12. 77 0
      service/cache/redisx/lockx.go
  13. 18 0
      service/cache/redisx/pipelinex.go
  14. 17 0
      service/cache/redisx/pubsubx.go
  15. 14 0
      service/cache/redisx/scriptx.go
  16. 13 0
      service/cache/redisx/setx.go
  17. 58 0
      service/cache/redisx/streamx.go
  18. 28 0
      service/cache/redisx/stringx.go
  19. 17 0
      service/cache/redisx/zsetx.go
  20. 50 0
      service/cache/stale.go
  21. 1 0
      service/cmd/showLog.sh
  22. 92 0
      service/controllers/auth.go
  23. 0 0
      service/controllers/response.go
  24. 14 0
      service/domain/authDomain.go
  25. 6 0
      service/dto/authDTO.go
  26. 13 9
      service/go.mod
  27. 24 24
      service/go.sum
  28. 4 0
      service/main.go
  29. 63 0
      service/middleware/auth.go
  30. 70 0
      service/repositories/redis/authRepo.go
  31. 10 0
      service/repositories/repositories.go
  32. 30 0
      service/routes/authRoutes.go
  33. 11 1
      service/routes/routes.go
  34. 106 0
      service/services/authServices.go
  35. 8 19
      service/taskQueue/taskQueue.go
  36. 88 0
      service/utils/casdoor/casdoor.go
  37. 4 0
      src/main.ts
  38. 21 0
      src/router/guard.ts
  39. 15 3
      src/router/index.ts
  40. 47 0
      src/service/auth/index.ts
  41. 4 0
      src/service/auth/interface.ts
  42. 69 28
      src/service/fetchClient.ts
  43. 95 6
      src/service/serverReq.ts
  44. 11 9
      src/stores/user.ts
  45. 63 63
      src/types/auto-imports.d.ts
  46. 30 0
      src/utils/casdoor/casdoor.ts
  47. 0 1
      src/views/sites-analysis/index.vue
  48. 3 0
      vite.config.ts

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
     "@unocss/transformer-variant-group": "^66.3.3",
     "@vueuse/components": "^13.5.0",
     "@vueuse/core": "^13.5.0",
+    "casdoor-vue-sdk": "^1.6.0",
     "chroma-js": "^3.1.2",
     "dayjs": "^1.11.18",
     "es-toolkit": "^1.39.8",

File diff suppressed because it is too large
+ 253 - 235
pnpm-lock.yaml


+ 326 - 0
service/Untitled-1

@@ -0,0 +1,326 @@
+// main.go
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"strconv"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"github.com/google/uuid"
+	"github.com/redis/go-redis/v9"
+)
+
+var (
+	ctx = context.Background()
+	rdb *redis.Client
+
+	// 从 env 加载
+	clientID     = os.Getenv("CASDOOR_CLIENT_ID")
+	clientSecret = os.Getenv("CASDOOR_CLIENT_SECRET")
+	authURL      = os.Getenv("CASDOOR_AUTHORIZE_URL") // e.g. https://door.example.com/login/oauth/authorize
+	tokenURL     = os.Getenv("CASDOOR_TOKEN_URL")     // e.g. https://door.example.com/api/login/oauth/access_token
+	redirectURI  = os.Getenv("APP_REDIRECT_URI")      // e.g. https://api.example.com/api/auth/callback
+	frontendURL  = os.Getenv("FRONTEND_URL")          // e.g. https://app.example.com
+	// session cookie name
+	sessionCookieName = "sid"
+	// session TTL (sync with refresh token validity; e.g. 7 days)
+	sessionTTL = 7 * 24 * time.Hour
+)
+
+type OAuthTokenResp struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"` // 秒
+	// 可能还有其他字段,按实际返回解析
+}
+
+/* ------------------- Redis helpers ------------------- */
+func initRedis() {
+	rdb = redis.NewClient(&redis.Options{
+		Addr:     "127.0.0.1:6379", // env 改造
+		Password: "",
+		DB:       0,
+	})
+	if _, err := rdb.Ping(ctx).Result(); err != nil {
+		log.Fatalf("redis connect error: %v", err)
+	}
+}
+
+func storeSession(sid string, token OAuthTokenResp) error {
+	key := fmt.Sprintf("session:%s", sid)
+	exp := time.Now().Add(sessionTTL)
+	accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
+	fields := map[string]interface{}{
+		"refresh_token": token.RefreshToken,
+		"access_token":  token.AccessToken,
+		"access_exp":    accessExp,
+	}
+	if err := rdb.HSet(ctx, key, fields).Err(); err != nil {
+		return err
+	}
+	return rdb.ExpireAt(ctx, key, exp).Err()
+}
+
+func getSession(sid string) (map[string]string, error) {
+	key := fmt.Sprintf("session:%s", sid)
+	res, err := rdb.HGetAll(ctx, key).Result()
+	if err != nil {
+		return nil, err
+	}
+	if len(res) == 0 {
+		return nil, errors.New("no session")
+	}
+	return res, nil
+}
+
+func deleteSession(sid string) error {
+	key := fmt.Sprintf("session:%s", sid)
+	return rdb.Del(ctx, key).Err()
+}
+
+/* ------------------- Casdoor interaction helpers ------------------- */
+
+// 标准 OAuth2 token exchange
+func exchangeCodeForToken(code string) (OAuthTokenResp, error) {
+	form := map[string]string{
+		"grant_type":    "authorization_code",
+		"code":          code,
+		"redirect_uri":  redirectURI,
+		"client_id":     clientID,
+		"client_secret": clientSecret,
+	}
+	return postTokenForm(form)
+}
+
+func refreshWithRefreshToken(refreshToken string) (OAuthTokenResp, error) {
+	form := map[string]string{
+		"grant_type":    "refresh_token",
+		"refresh_token": refreshToken,
+		"client_id":     clientID,
+		"client_secret": clientSecret,
+	}
+	return postTokenForm(form)
+}
+
+func postTokenForm(form map[string]string) (OAuthTokenResp, error) {
+	bodyBytes, _ := json.Marshal(form)
+	req, _ := http.NewRequest("POST", tokenURL, bytes.NewBuffer(bodyBytes))
+	req.Header.Set("Content-Type", "application/json") // 某些 Casdoor 期望 application/x-www-form-urlencoded:根据 Casdoor 实际调整
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return OAuthTokenResp{}, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		return OAuthTokenResp{}, fmt.Errorf("token endpoint returned %d", resp.StatusCode)
+	}
+	var tr OAuthTokenResp
+	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
+		return OAuthTokenResp{}, err
+	}
+	return tr, nil
+}
+
+/* ------------------- Handlers ------------------- */
+
+// GET /api/auth/login -> 重定向到 Casdoor authorize URL
+func loginRedirect(c *fiber.Ctx) error {
+	// state 可用于防 CSRF
+	state := uuid.NewString()
+	// 保存 state -> 可以放到 Redis 验证,不过此处省略
+	u := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&state=%s", authURL, clientID, redirectURI, state)
+	return c.Redirect(u, fiber.StatusFound)
+}
+
+// GET /api/auth/callback?code=...&state=...
+func callbackHandler(c *fiber.Ctx) error {
+	code := c.Query("code")
+	if code == "" {
+		return c.Status(400).SendString("missing code")
+	}
+
+	// 用 code 换 token
+	tokenResp, err := exchangeCodeForToken(code)
+	if err != nil {
+		log.Println("exchange code err:", err)
+		return c.Status(500).JSON(fiber.Map{"error": "token exchange failed"})
+	}
+
+	// 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存)
+	sid := uuid.NewString()
+	if err := storeSession(sid, tokenResp); err != nil {
+		log.Println("store session err:", err)
+	}
+
+	// 设置 HttpOnly cookie 保存 sid
+	c.Cookie(&fiber.Cookie{
+		Name:     sessionCookieName,
+		Value:    sid,
+		Expires:  time.Now().Add(sessionTTL),
+		HTTPOnly: true,
+		Secure:   false, // production: true
+		SameSite: "Lax",
+		Path:     "/",
+	})
+
+	// 最后重定向回前端单页 app
+	return c.Redirect(frontendURL, fiber.StatusFound)
+}
+
+// GET /api/auth/session  -> 前端载入时调用,返回 access_token(若存在)
+func sessionHandler(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	if sid == "" {
+		return c.Status(200).JSON(fiber.Map{"authenticated": false})
+	}
+	sess, err := getSession(sid)
+	if err != nil {
+		// session 不存在或已过期
+		// 清除 cookie
+		c.Cookie(&fiber.Cookie{
+			Name:     sessionCookieName,
+			Value:    "",
+			Expires:  time.Now().Add(-time.Hour),
+			HTTPOnly: true,
+			Secure:   false,
+			Path:     "/",
+		})
+		return c.Status(200).JSON(fiber.Map{"authenticated": false})
+	}
+	// 若 access_token 仍有效则直接返回
+	access := sess["access_token"]
+	accessExpStr := sess["access_exp"]
+	if access != "" && accessExpStr != "" {
+		accessExp, _ := strconv.ParseInt(accessExpStr, 10, 64)
+		if time.Now().Unix() < accessExp {
+			return c.JSON(fiber.Map{"authenticated": true, "access_token": access})
+		}
+	}
+
+	// access 已过期 -> 试着用 refresh token 刷新(后端直接刷新并返回)
+	refreshToken := sess["refresh_token"]
+	if refreshToken == "" {
+		return c.Status(200).JSON(fiber.Map{"authenticated": false})
+	}
+	newTok, err := refreshWithRefreshToken(refreshToken)
+	if err != nil {
+		// refresh 失败 -> session 失效
+		_ = deleteSession(sid)
+		c.Cookie(&fiber.Cookie{
+			Name:     sessionCookieName,
+			Value:    "",
+			Expires:  time.Now().Add(-time.Hour),
+			HTTPOnly: true,
+			Secure:   false,
+			Path:     "/",
+		})
+		return c.Status(200).JSON(fiber.Map{"authenticated": false})
+	}
+
+	// 更新 session(rotate)
+	if err := storeSession(sid, newTok); err != nil {
+		log.Println("update session err:", err)
+	}
+	return c.JSON(fiber.Map{"authenticated": true, "access_token": newTok.AccessToken})
+}
+
+// POST /api/auth/refresh -> 前端拦截器触发(带 sid cookie),后端用存在 Redis 的 refresh_token 调 Casdoor 刷新
+func refreshHandler(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	if sid == "" {
+		return c.Status(401).JSON(fiber.Map{"error": "no session"})
+	}
+	sess, err := getSession(sid)
+	if err != nil {
+		return c.Status(401).JSON(fiber.Map{"error": "session not found"})
+	}
+	rt := sess["refresh_token"]
+	if rt == "" {
+		return c.Status(401).JSON(fiber.Map{"error": "no refresh token"})
+	}
+
+	newTok, err := refreshWithRefreshToken(rt)
+	if err != nil {
+		// refresh token 也过期或无效
+		_ = deleteSession(sid)
+		c.Cookie(&fiber.Cookie{
+			Name:     sessionCookieName,
+			Value:    "",
+			Expires:  time.Now().Add(-time.Hour),
+			HTTPOnly: true,
+			Secure:   false,
+			Path:     "/",
+		})
+		return c.Status(401).JSON(fiber.Map{"error": "refresh failed"})
+	}
+
+	// rotate: 更新 Redis 存储(用同 sid 存新 refresh token + new access)
+	if err := storeSession(sid, newTok); err != nil {
+		log.Println("store session after refresh err:", err)
+	}
+
+	// 返回新的 access token 给前端(前端把它放内存)
+	return c.JSON(fiber.Map{
+		"access_token": newTok.AccessToken,
+		"expires_in":   newTok.ExpiresIn,
+	})
+}
+
+// POST /api/auth/logout
+func logoutHandler(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	if sid != "" {
+		_ = deleteSession(sid)
+	}
+	// 清除 cookie
+	c.Cookie(&fiber.Cookie{
+		Name:     sessionCookieName,
+		Value:    "",
+		Expires:  time.Now().Add(-time.Hour),
+		HTTPOnly: true,
+		Secure:   false,
+		Path:     "/",
+	})
+	return c.JSON(fiber.Map{"ok": true})
+}
+
+/* ------------------- main ------------------- */
+func main() {
+	// 检查必需 env
+	if clientID == "" || clientSecret == "" || authURL == "" || tokenURL == "" || redirectURI == "" || frontendURL == "" {
+		log.Println("Please set CASDOOR_CLIENT_ID, CASDOOR_CLIENT_SECRET, CASDOOR_AUTHORIZE_URL, CASDOOR_TOKEN_URL, APP_REDIRECT_URI, FRONTEND_URL")
+		// 继续运行以便开发测试,但最好中断
+	}
+
+	initRedis()
+
+	app := fiber.New()
+
+	app.Get("/api/auth/login", loginRedirect)
+	app.Get("/api/auth/callback", callbackHandler)
+	app.Get("/api/auth/session", sessionHandler)
+	app.Post("/api/auth/refresh", refreshHandler)
+	app.Post("/api/auth/logout", logoutHandler)
+
+	// example protected endpoint: 前端带 access_token in Authorization header
+	app.Get("/api/me", func(c *fiber.Ctx) error {
+		auth := c.Get("Authorization")
+		if auth == "" {
+			return c.Status(401).JSON(fiber.Map{"error": "missing token"})
+		}
+		// 这里可以验证 Casdoor 的 access token 或调用 Casdoor 的 userinfo endpoint
+		// 为示例我们只是回显(生产需校验)
+		return c.JSON(fiber.Map{"token": auth})
+	})
+
+	log.Fatal(app.Listen(":3000"))
+}

+ 234 - 21
service/cache/cache.go

@@ -2,37 +2,250 @@ package cache
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"time"
 
-	"github.com/go-redis/redis/v8"
-)
+	"fwh-toolbox-server/cache/redisx"
 
-var C *redis.Client
+	"github.com/redis/go-redis/v9"
+)
 
 func Init(addr string) {
-	C = redis.NewClient(&redis.Options{
-		Addr: addr,
-		DB:   12,
-	})
-
-	ctx := context.Background()
-	if err := C.Do(ctx, "PING").Err(); err != nil {
-		fmt.Println(addr)
-		errStr := fmt.Sprintf("Redis 无法访问: %s", err.Error())
-		panic(errStr)
+	opt := redisx.Config{
+		Addrs: []string{addr},
+		DB:    12,
 	}
+	redisx.Init(&opt)
 }
 
-func GetOrSet(ctx context.Context, key string, ttl time.Duration, fetch func() (interface{}, error)) (interface{}, error) {
-	val, err := C.Get(ctx, key).Result()
-	if err == nil {
-		return val, nil
+// ErrNotFound 用于表示缓存未命中
+var ErrNotFound = errors.New("cachex: not found")
+
+// helper: compute full key with prefix
+func fullKey(prefix, key string) string {
+	if prefix == "" {
+		return key
+	}
+	return fmt.Sprintf("%s:%s", prefix, key)
+}
+
+// RegisterHitHook / RegisterMissHook: 简单 hook,用户可利用来统计
+var (
+	hitHook  func(key string)
+	missHook func(key string)
+)
+
+func RegisterHitHook(fn func(key string)) {
+	hitHook = fn
+}
+func RegisterMissHook(fn func(key string)) {
+	missHook = fn
+}
+
+// internal helpers to call hooks safely
+func onHit(key string) {
+	if hitHook != nil {
+		go func() { defer func() { recover() }(); hitHook(key) }()
+	}
+}
+func onMiss(key string) {
+	if missHook != nil {
+		go func() { defer func() { recover() }(); missHook(key) }()
 	}
-	result, err := fetch()
+}
+
+/********* 字符串操作 *********/
+// Set string value
+func Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
+	return redisx.Set(ctx, key, value, ttl)
+}
+
+// Get string value
+func Get(ctx context.Context, key string) (string, error) {
+	val, err := redisx.Get(ctx, key)
 	if err != nil {
-		return nil, err
+		return "", err
 	}
-	C.Set(ctx, key, result, ttl)
-	return result, nil
+	return val, nil
+}
+
+func MSet(ctx context.Context, pairs ...interface{}) error {
+	return redisx.MSet(ctx, pairs...)
+}
+
+func MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
+	return redisx.MGet(ctx, keys...)
+}
+
+func Incr(ctx context.Context, key string) (int64, error) {
+	return redisx.Incr(ctx, key)
+}
+
+/********* 哈希操作 *********/
+
+func HSet(ctx context.Context, key string, values map[string]interface{}) error {
+	return redisx.HSet(ctx, key, values)
+}
+
+func HGet(ctx context.Context, key, field string) (string, error) {
+	return redisx.HGet(ctx, key, field)
+}
+
+func HGetAll(ctx context.Context, key string) (map[string]string, error) {
+	return redisx.HGetAll(ctx, key)
+}
+
+/********* 列表操作 *********/
+
+func LPush(ctx context.Context, key string, values ...interface{}) error {
+	return redisx.LPush(ctx, key, values...)
+}
+
+func RPush(ctx context.Context, key string, values ...interface{}) error {
+	return redisx.RPush(ctx, key, values...)
+}
+
+func LPop(ctx context.Context, key string) (string, error) {
+	return redisx.LPop(ctx, key)
+}
+
+/********* 集合操作 *********/
+
+func SAdd(ctx context.Context, key string, members ...interface{}) error {
+	return redisx.SAdd(ctx, key, members...)
+}
+
+func SMembers(ctx context.Context, key string) ([]string, error) {
+	return redisx.SMembers(ctx, key)
+}
+
+/********* 有序集合操作 *********/
+
+func ZAdd(ctx context.Context, key string, members ...redis.Z) error {
+	return redisx.ZAdd(ctx, key, members...)
+}
+
+func ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
+	return redisx.ZRange(ctx, key, start, stop)
+}
+
+/********* Stream(流)操作 *********/
+
+// 添加消息
+func XAdd(ctx context.Context, stream string, values map[string]interface{}) (string, error) {
+	return redisx.XAdd(ctx, stream, values)
+}
+
+// 读取消息
+func XRead(ctx context.Context, streams map[string]string, count int64) ([]redis.XStream, error) {
+	return redisx.XRead(ctx, streams, count)
+}
+
+// 创建消费者组
+func XGroupCreate(ctx context.Context, stream, group, start string) error {
+	return redisx.XGroupCreate(ctx, stream, group, start)
+}
+
+// 从消费者组读取
+func XReadGroup(ctx context.Context, group, consumer string, streams map[string]string, count int64) ([]redis.XStream, error) {
+	return redisx.XReadGroup(ctx, group, consumer, streams, count)
+}
+
+// 确认消息
+func XAck(ctx context.Context, stream, group string, ids ...string) error {
+	return redisx.XAck(ctx, stream, group, ids...)
+}
+
+/********* 分布式锁操作 *********/
+
+type RedisLock = redisx.RedisLock
+
+func TryLock(ctx context.Context, key string, ttl time.Duration) (*RedisLock, bool, error) {
+	return redisx.TryLock(ctx, key, ttl)
+}
+
+func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Duration) (*RedisLock, error) {
+	return redisx.Lock(ctx, key, ttl, retryInterval, timeout)
+}
+
+/********* JSON操作 *********/
+
+// SetJSON 将任意结构体序列化为 JSON 存入 Redis
+func SetJSON(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
+	return redisx.SetJSON(ctx, key, value, ttl)
+}
+
+// GetJSON 从 Redis 获取并反序列化为指定结构体
+func GetJSON[T any](ctx context.Context, key string, dest *T) error {
+	return redisx.GetJSON[T](ctx, key, dest)
+}
+
+// MustGetJSON 返回默认值版本(未命中时返回 nil)
+func MustGetJSON[T any](ctx context.Context, key string) *T {
+	return redisx.MustGetJSON[T](ctx, key)
+}
+
+// HSetJSON 在 Hash 中存储 JSON
+func HSetJSON(ctx context.Context, key, field string, value interface{}) error {
+	return redisx.HSetJSON(ctx, key, field, value)
+}
+
+// HGetJSON 从 Hash 中读取 JSON
+func HGetJSON[T any](ctx context.Context, key, field string, dest *T) error {
+	return redisx.HGetJSON[T](ctx, key, field, dest)
+}
+
+// MGetJSON 支持批量获取多个 JSON 对象
+func MGetJSON[T any](ctx context.Context, keys ...string) (map[string]*T, []string, error) {
+	return redisx.MGetJSON[T](ctx, keys...)
+}
+
+/********* Lua脚本操作 *********/
+
+func RunScript(ctx context.Context, script string, keys []string, args ...interface{}) (interface{}, error) {
+	return redisx.RunScript(ctx, script, keys, args...)
+}
+
+/********* 发布订阅操作 *********/
+
+func Publish(ctx context.Context, channel string, msg interface{}) error {
+	return redisx.Publish(ctx, channel, msg)
+}
+
+func Subscribe(ctx context.Context, channel string) *redis.PubSub {
+	return redisx.Subscribe(ctx, channel)
+}
+
+/********* 管道与事务 *********/
+
+func Pipeline(ctx context.Context, fn func(redis.Pipeliner) error) ([]redis.Cmder, error) {
+	return redisx.Pipeline(ctx, fn)
+}
+
+/********* 工具方法 *********/
+
+// Delete
+func Delete(ctx context.Context, keys ...string) error {
+	return redisx.Del(ctx, keys...)
+}
+
+// Expire
+func Expire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
+	return redisx.Expire(ctx, key, ttl)
+}
+
+// ExpireAt
+func ExpireAt(ctx context.Context, key string, tm time.Time) (bool, error) {
+	return redisx.ExpireAt(ctx, key, tm)
+}
+
+// TTL
+func TTL(ctx context.Context, key string) (time.Duration, error) {
+	return redisx.TTL(ctx, key)
+}
+
+// Exists
+func Exists(ctx context.Context, key string) (bool, error) {
+	return redisx.Exists(ctx, key)
 }

+ 169 - 0
service/cache/cache_aside.go

@@ -0,0 +1,169 @@
+package cache
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"fwh-toolbox-server/cache/redisx"
+
+	"go.uber.org/zap"
+)
+
+// LoaderFunc 定义
+type LoaderFunc[T any] func(ctx context.Context) (*T, error)
+
+// GetOrSet: cache-aside 模式,带分布式锁防击穿与异步写缓存
+func GetOrSet[T any](
+	ctx context.Context,
+	key string,
+	loader LoaderFunc[T],
+	opts ...Option,
+) (*T, error) {
+	o := defaultOptions()
+	for _, op := range opts {
+		op(o)
+	}
+	full := fullKey(o.prefix, key)
+
+	// 1. 尝试缓存读取(JSON)
+	var out T
+	if err := redisx.GetJSON(ctx, full, &out); err == nil {
+		onHit(full)
+		return &out, nil
+	} else {
+		onMiss(full)
+	}
+
+	// 2. 未命中:尝试加分布式锁(如果启用)
+	if o.lockEnabled {
+		lockKey := full + ":lock"
+		lock, _, err := redisx.TryLock(ctx, lockKey, o.lockTTL)
+		if err == nil && lock != nil {
+			// acquired lock
+			defer func() {
+				if err := lock.Release(ctx); err != nil {
+					// log but don't break business
+					zap.L().Error("cachex: failed to release lock", zap.String("key", lockKey), zap.String("err", err.Error()))
+				}
+			}()
+
+			// double check cache (防止其它线程已写入)
+			if err := redisx.GetJSON(ctx, full, &out); err == nil {
+				onHit(full)
+				return &out, nil
+			}
+
+			// 调用 loader 加载真实数据
+			data, err := loader(ctx)
+			if err != nil {
+				return nil, err
+			}
+			if data == nil {
+				// 业务决定:我们不写空值,直接返回 not found
+				return nil, errors.New("cachex: loader returned nil")
+			}
+
+			// 写入缓存(可异步)
+			if o.async {
+				go func() {
+					if err := redisx.SetJSON(context.Background(), full, data, o.ttl); err != nil {
+						zap.L().Error("cachex: async set failed", zap.String("full", full), zap.String("err", err.Error()))
+					}
+				}()
+			} else {
+				if err := redisx.SetJSON(ctx, full, data, o.ttl); err != nil {
+					zap.L().Error("cachex: set failed", zap.String("full", full), zap.String("err", err.Error()))
+				}
+			}
+			return data, nil
+		}
+		// 未能获得锁:改为短轮询等待缓存(其他 worker 可能正在写)
+		start := time.Now()
+		for {
+			// 尝试从缓存读取
+			var r T
+			if err := redisx.GetJSON(ctx, full, &r); err == nil {
+				onHit(full)
+				return &r, nil
+			}
+			if time.Since(start) >= o.waitForCache {
+				// 超时则 fallback 直接调用 loader(避免长时间阻塞)
+				break
+			}
+			time.Sleep(50 * time.Millisecond)
+		}
+	}
+
+	// 最后兜底:直接调用 loader(不持锁)
+	data, err := loader(ctx)
+	if err != nil {
+		return nil, err
+	}
+	if data == nil {
+		return nil, errors.New("cachex: loader returned nil")
+	}
+	// 写入缓存(异步)
+	if o.async {
+		go func() {
+			if err := redisx.SetJSON(context.Background(), full, data, o.ttl); err != nil {
+				zap.L().Error("cachex: async set fallback failed", zap.String("full", full), zap.String("err", err.Error()))
+			}
+		}()
+	} else {
+		_ = redisx.SetJSON(ctx, full, data, o.ttl)
+	}
+	return data, nil
+}
+
+// GetOrSetMany: 批量模式,keys 与 loaderFunc 的约定:loader 需要知道哪些 keys 要加载
+// 这里提供一个通用版本:loader 接收 keys 并返回 map[key]*T
+type BatchLoaderFunc[T any] func(ctx context.Context, keys []string) (map[string]*T, error)
+
+// GetOrSetMany: 先 MGET 缓存,找出缺失 keys,调用 loader 批量加载并回写缓存
+func GetOrSetMany[T any](
+	ctx context.Context,
+	keys []string,
+	loader BatchLoaderFunc[T],
+	opts ...Option,
+) (map[string]*T, error) {
+	o := defaultOptions()
+	for _, op := range opts {
+		op(o)
+	}
+	// prefix + full keys
+	fullKeys := make([]string, len(keys))
+	for i, k := range keys {
+		fullKeys[i] = fullKey(o.prefix, k)
+	}
+
+	// MGET via redisx.getClient().MGet
+	out, missing, err := redisx.MGetJSON[T](ctx, fullKeys...)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(missing) == 0 {
+		// 全命中
+		return out, nil
+	}
+
+	// 批量 loader
+	loaded, err := loader(ctx, missing)
+	if err != nil {
+		return nil, err
+	}
+
+	fullKeysLoaded := make(map[string]*T)
+	for k, v := range loaded {
+		// prefix + full keys
+		fullKeysLoaded[fullKey(o.prefix, k)] = v
+		out[k] = v
+	}
+
+	// 回写缓存(批量 MSET)
+	if err := redisx.MSetJSON[T](ctx, fullKeysLoaded, o.ttl); err != nil {
+		zap.L().Error("cachex: batch mset exec failed: %v", zap.String("err", err.Error()))
+	}
+	return out, nil
+}

+ 50 - 0
service/cache/options.go

@@ -0,0 +1,50 @@
+package cache
+
+import "time"
+
+type Option func(*options)
+
+type options struct {
+	ttl            time.Duration // 缓存 TTL
+	async          bool          // 是否异步写缓存
+	lockEnabled    bool          // 是否启用分布式锁防击穿
+	lockTTL        time.Duration // 锁过期
+	prefix         string        // key 前缀
+	waitForCache   time.Duration // 获取不到锁时等待缓存可用的最大时间(轮询)
+	staleThreshold time.Duration // stale 模式中视为“过期但可用”的阈值
+}
+
+func defaultOptions() *options {
+	return &options{
+		ttl:            5 * time.Minute,
+		async:          true,
+		lockEnabled:    true,
+		lockTTL:        5 * time.Second,
+		prefix:         "",
+		waitForCache:   500 * time.Millisecond,
+		staleThreshold: 30 * time.Second,
+	}
+}
+
+// 配置 helper
+func WithTTL(ttl time.Duration) Option {
+	return func(o *options) { o.ttl = ttl }
+}
+func WithAsync(async bool) Option {
+	return func(o *options) { o.async = async }
+}
+func WithLock(enabled bool) Option {
+	return func(o *options) { o.lockEnabled = enabled }
+}
+func WithLockTTL(ttl time.Duration) Option {
+	return func(o *options) { o.lockTTL = ttl }
+}
+func WithPrefix(prefix string) Option {
+	return func(o *options) { o.prefix = prefix }
+}
+func WithWaitForCache(d time.Duration) Option {
+	return func(o *options) { o.waitForCache = d }
+}
+func WithStaleThreshold(d time.Duration) Option {
+	return func(o *options) { o.staleThreshold = d }
+}

+ 72 - 0
service/cache/redisx/client.go

@@ -0,0 +1,72 @@
+package redisx
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// 统一初始化与连接管理(支持单机、哨兵、集群)
+
+var (
+	client redis.UniversalClient
+	once   sync.Once
+)
+
+type Config struct {
+	Mode         string
+	Addrs        []string
+	Username     string
+	Password     string
+	DB           int
+	MasterName   string
+	PoolSize     int
+	MinIdleConns int
+	DialTimeout  time.Duration
+	ReadTimeout  time.Duration
+	WriteTimeout time.Duration
+}
+
+func Init(cfg *Config) {
+	once.Do(func() {
+		client = redis.NewUniversalClient(&redis.UniversalOptions{
+			Addrs:        cfg.Addrs,
+			Username:     cfg.Username,
+			Password:     cfg.Password,
+			DB:           cfg.DB,
+			MasterName:   cfg.MasterName,
+			PoolSize:     cfg.PoolSize,
+			MinIdleConns: cfg.MinIdleConns,
+			DialTimeout:  cfg.DialTimeout,
+			ReadTimeout:  cfg.ReadTimeout,
+			WriteTimeout: cfg.WriteTimeout,
+		})
+
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		if ping := client.Do(ctx, "PING"); ping.Err() != nil {
+			fmt.Println(cfg.Addrs)
+			errStr := fmt.Sprintf("Redis 无法访问: %s", ping.Err().Error())
+			panic(errStr)
+		}
+	})
+}
+
+// getClient 获取 Redis 客户端
+func getClient() redis.UniversalClient {
+	if client == nil {
+		panic("redisx not initialized, call redisx.Init() first")
+	}
+	return client
+}
+
+// Close 关闭 Redis 连接
+func Close() error {
+	if client != nil {
+		return client.Close()
+	}
+	return nil
+}

+ 17 - 0
service/cache/redisx/hashx.go

@@ -0,0 +1,17 @@
+package redisx
+
+import "context"
+
+// 哈希封装
+
+func HSet(ctx context.Context, key string, values map[string]interface{}) error {
+	return getClient().HSet(ctx, key, values).Err()
+}
+
+func HGet(ctx context.Context, key, field string) (string, error) {
+	return getClient().HGet(ctx, key, field).Result()
+}
+
+func HGetAll(ctx context.Context, key string) (map[string]string, error) {
+	return getClient().HGetAll(ctx, key).Result()
+}

+ 34 - 0
service/cache/redisx/helper.go

@@ -0,0 +1,34 @@
+package redisx
+
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// 工具方法(Del、Expire、TTL、Exists、Scan 等)
+
+func Del(ctx context.Context, keys ...string) error {
+	if len(keys) == 0 {
+		return fmt.Errorf("no key provided")
+	}
+
+	return getClient().Del(ctx, keys...).Err()
+}
+
+func Expire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
+	return getClient().Expire(ctx, key, ttl).Result()
+}
+
+func ExpireAt(ctx context.Context, key string, tm time.Time) (bool, error) {
+	return getClient().ExpireAt(ctx, key, tm).Result()
+}
+
+func TTL(ctx context.Context, key string) (time.Duration, error) {
+	return getClient().TTL(ctx, key).Result()
+}
+
+func Exists(ctx context.Context, key string) (bool, error) {
+	n, err := getClient().Exists(ctx, key).Result()
+	return n > 0, err
+}

+ 134 - 0
service/cache/redisx/jsonx.go

@@ -0,0 +1,134 @@
+package redisx
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// SetJSON 将任意结构体序列化为 JSON 存入 Redis
+func SetJSON(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
+	if value == nil {
+		return errors.New("redisx.SetJSON: value is nil")
+	}
+	data, err := json.Marshal(value)
+	if err != nil {
+		return err
+	}
+	return getClient().Set(ctx, key, data, ttl).Err()
+}
+
+// GetJSON 从 Redis 获取并反序列化为指定结构体
+func GetJSON[T any](ctx context.Context, key string, dest *T) error {
+	if dest == nil {
+		return errors.New("redisx.GetJSON: dest is nil")
+	}
+	val, err := getClient().Get(ctx, key).Bytes()
+	if err != nil {
+		if err == redis.Nil {
+			return errors.New("redisx.GetJSON: key not found")
+		}
+		return err
+	}
+	return json.Unmarshal(val, dest)
+}
+
+// MustGetJSON 返回默认值版本(未命中时返回 nil)
+func MustGetJSON[T any](ctx context.Context, key string) *T {
+	var out T
+	err := GetJSON(ctx, key, &out)
+	if err != nil {
+		return nil
+	}
+	return &out
+}
+
+// HSetJSON 在 Hash 中存储 JSON
+func HSetJSON(ctx context.Context, key, field string, value interface{}) error {
+	data, err := json.Marshal(value)
+	if err != nil {
+		return err
+	}
+	return getClient().HSet(ctx, key, field, data).Err()
+}
+
+// HGetJSON 从 Hash 中读取 JSON
+func HGetJSON[T any](ctx context.Context, key, field string, dest *T) error {
+	val, err := getClient().HGet(ctx, key, field).Bytes()
+	if err != nil {
+		if err == redis.Nil {
+			return errors.New("redisx.HGetJSON: field not found")
+		}
+		return err
+	}
+	return json.Unmarshal(val, dest)
+}
+
+// MGetJSON 支持批量获取多个 JSON 对象
+// return (items []*T, missing []string, err error)
+// missing为未命中的key列表
+func MGetJSON[T any](ctx context.Context, keys ...string) (map[string]*T, []string, error) {
+	if len(keys) == 0 {
+		return nil, nil, nil
+	}
+	results, err := getClient().MGet(ctx, keys...).Result()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	items := make(map[string]*T)
+	missing := make([]string, 0)
+	for i, res := range results {
+		k := keys[i]
+		if res == nil {
+			missing = append(missing, k)
+			continue
+		}
+		var v T
+		if err := json.Unmarshal([]byte(res.(string)), &v); err != nil {
+			missing = append(missing, k)
+			continue
+		}
+		items[k] = &v
+	}
+	return items, missing, nil
+}
+
+// MSetJSON 支持批量多个 JSON 对象
+func MSetJSON[T any](ctx context.Context, kvs map[string]*T, ttl time.Duration) error {
+	if len(kvs) == 0 {
+		return nil
+	}
+
+	// 如果 ttl == 0,可以直接用 MSet
+	if ttl == 0 {
+		args := make([]any, 0, len(kvs)*2)
+		for k, v := range kvs {
+			b, err := json.Marshal(v)
+			if err != nil {
+				return err
+			}
+			args = append(args, k, b)
+		}
+		return MSet(ctx, args...)
+	}
+
+	// 否则使用 pipeline 设置过期时间
+	_, err := Pipeline(ctx, func(p redis.Pipeliner) error {
+		for k, v := range kvs {
+			if v == nil {
+				continue
+			}
+			b, err := json.Marshal(v)
+			if err != nil {
+				return err
+			}
+			p.Set(ctx, k, b, ttl)
+		}
+		return nil
+	})
+	return err
+}

+ 17 - 0
service/cache/redisx/listx.go

@@ -0,0 +1,17 @@
+package redisx
+
+import "context"
+
+// 列表封装
+
+func LPush(ctx context.Context, key string, values ...interface{}) error {
+	return getClient().LPush(ctx, key, values...).Err()
+}
+
+func RPush(ctx context.Context, key string, values ...interface{}) error {
+	return getClient().RPush(ctx, key, values...).Err()
+}
+
+func LPop(ctx context.Context, key string) (string, error) {
+	return getClient().LPop(ctx, key).Result()
+}

+ 77 - 0
service/cache/redisx/lockx.go

@@ -0,0 +1,77 @@
+package redisx
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/redis/go-redis/v9"
+)
+
+// 分布式锁
+
+type RedisLock struct {
+	key   string
+	token string
+	ttl   time.Duration
+}
+
+// TryLock 非阻塞尝试加锁
+func TryLock(ctx context.Context, key string, ttl time.Duration) (*RedisLock, bool, error) {
+	token := uuid.NewString()
+	ok, err := getClient().SetNX(ctx, key, token, ttl).Result()
+	if err != nil {
+		return nil, false, err
+	}
+	if !ok {
+		return nil, false, nil
+	}
+	return &RedisLock{key: key, token: token, ttl: ttl}, true, nil
+}
+
+// Lock 阻塞加锁,可设置重试间隔和超时
+func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Duration) (*RedisLock, error) {
+	start := time.Now()
+	for {
+		lock, ok, err := TryLock(ctx, key, ttl)
+		if err != nil {
+			return nil, err
+		}
+		if ok {
+			return lock, nil
+		}
+		if timeout > 0 && time.Since(start) >= timeout {
+			return nil, errors.New("lock timeout")
+		}
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-time.After(retryInterval):
+		}
+	}
+}
+
+// Release 安全释放锁(Lua 脚本)
+func (l *RedisLock) Release(ctx context.Context) error {
+	const luaScript = `
+if redis.call("get", KEYS[1]) == ARGV[1] then
+	return redis.call("del", KEYS[1])
+else
+	return 0
+end
+`
+	res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{l.key}, l.token).Int()
+	if err != nil {
+		return err
+	}
+	if res == 0 {
+		return errors.New("unlock failed: not owner")
+	}
+	return nil
+}
+
+// SafeRelease 带日志保护版本
+func (l *RedisLock) SafeRelease(ctx context.Context) {
+	_ = l.Release(ctx)
+}

+ 18 - 0
service/cache/redisx/pipelinex.go

@@ -0,0 +1,18 @@
+package redisx
+
+import (
+	"context"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// 管道与事务
+
+func Pipeline(ctx context.Context, fn func(redis.Pipeliner) error) ([]redis.Cmder, error) {
+	pipe := getClient().Pipeline()
+	err := fn(pipe)
+	if err != nil {
+		return nil, err
+	}
+	return pipe.Exec(ctx)
+}

+ 17 - 0
service/cache/redisx/pubsubx.go

@@ -0,0 +1,17 @@
+package redisx
+
+import (
+	"context"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// 发布订阅
+
+func Publish(ctx context.Context, channel string, msg interface{}) error {
+	return getClient().Publish(ctx, channel, msg).Err()
+}
+
+func Subscribe(ctx context.Context, channel string) *redis.PubSub {
+	return getClient().Subscribe(ctx, channel)
+}

+ 14 - 0
service/cache/redisx/scriptx.go

@@ -0,0 +1,14 @@
+package redisx
+
+import (
+	"context"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// Lua脚本封装
+
+func RunScript(ctx context.Context, script string, keys []string, args ...interface{}) (interface{}, error) {
+	s := redis.NewScript(script)
+	return s.Run(ctx, getClient(), keys, args...).Result()
+}

+ 13 - 0
service/cache/redisx/setx.go

@@ -0,0 +1,13 @@
+package redisx
+
+import "context"
+
+// 集合封装
+
+func SAdd(ctx context.Context, key string, members ...interface{}) error {
+	return getClient().SAdd(ctx, key, members...).Err()
+}
+
+func SMembers(ctx context.Context, key string) ([]string, error) {
+	return getClient().SMembers(ctx, key).Result()
+}

+ 58 - 0
service/cache/redisx/streamx.go

@@ -0,0 +1,58 @@
+package redisx
+
+import (
+	"context"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// Stream(流)封装
+
+// 添加消息
+func XAdd(ctx context.Context, stream string, values map[string]interface{}) (string, error) {
+	return getClient().XAdd(ctx, &redis.XAddArgs{
+		Stream: stream,
+		Values: values,
+	}).Result()
+}
+
+// 读取消息
+func XRead(ctx context.Context, streams map[string]string, count int64) ([]redis.XStream, error) {
+	args := &redis.XReadArgs{
+		Streams: flattenStreams(streams),
+		Count:   count,
+		Block:   0,
+	}
+	return getClient().XRead(ctx, args).Result()
+}
+
+// 创建消费者组
+func XGroupCreate(ctx context.Context, stream, group, start string) error {
+	return getClient().XGroupCreateMkStream(ctx, stream, group, start).Err()
+}
+
+// 从消费者组读取
+func XReadGroup(ctx context.Context, group, consumer string, streams map[string]string, count int64) ([]redis.XStream, error) {
+	args := &redis.XReadGroupArgs{
+		Group:    group,
+		Consumer: consumer,
+		Streams:  flattenStreams(streams),
+		Count:    count,
+		Block:    5 * time.Second,
+	}
+	return getClient().XReadGroup(ctx, args).Result()
+}
+
+// 确认消息
+func XAck(ctx context.Context, stream, group string, ids ...string) error {
+	return getClient().XAck(ctx, stream, group, ids...).Err()
+}
+
+func flattenStreams(m map[string]string) []string {
+	arr := make([]string, 0, len(m)*2)
+	for k, v := range m {
+		arr = append(arr, k, v)
+	}
+	return arr
+}

+ 28 - 0
service/cache/redisx/stringx.go

@@ -0,0 +1,28 @@
+package redisx
+
+import (
+	"context"
+	"time"
+)
+
+// 字符串操作封装
+
+func Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
+	return getClient().Set(ctx, key, value, ttl).Err()
+}
+
+func Get(ctx context.Context, key string) (string, error) {
+	return getClient().Get(ctx, key).Result()
+}
+
+func MSet(ctx context.Context, pairs ...interface{}) error {
+	return getClient().MSet(ctx, pairs...).Err()
+}
+
+func MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
+	return getClient().MGet(ctx, keys...).Result()
+}
+
+func Incr(ctx context.Context, key string) (int64, error) {
+	return getClient().Incr(ctx, key).Result()
+}

+ 17 - 0
service/cache/redisx/zsetx.go

@@ -0,0 +1,17 @@
+package redisx
+
+import (
+	"context"
+
+	"github.com/redis/go-redis/v9"
+)
+
+// 有序集合封装
+
+func ZAdd(ctx context.Context, key string, members ...redis.Z) error {
+	return getClient().ZAdd(ctx, key, members...).Err()
+}
+
+func ZRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
+	return getClient().ZRange(ctx, key, start, stop).Result()
+}

+ 50 - 0
service/cache/stale.go

@@ -0,0 +1,50 @@
+package cache
+
+import (
+	"context"
+
+	"fwh-toolbox-server/cache/redisx"
+
+	"go.uber.org/zap"
+)
+
+// GetOrSetStale: 如果缓存存在但 TTL 小于 threshold,则返回旧值并后台刷新
+func GetOrSetStale[T any](
+	ctx context.Context,
+	key string,
+	loader LoaderFunc[T],
+	opts ...Option,
+) (*T, error) {
+	o := defaultOptions()
+	for _, op := range opts {
+		op(o)
+	}
+	full := fullKey(o.prefix, key)
+
+	// 1) 尝试读取缓存并检查 TTL
+	var out T
+	if err := redisx.GetJSON(ctx, full, &out); err == nil {
+		// cache exists => check remaining TTL
+		ttl, err := redisx.TTL(ctx, full)
+		if err != nil {
+			// 无法获取 TTL 则直接返回缓存值
+			return &out, nil
+		}
+		if ttl <= 0 || ttl > o.staleThreshold {
+			// TTL 足够 -> 直接返回
+			return &out, nil
+		}
+		// TTL <= threshold -> 视为 stale:返回旧值并后台刷新
+		go func() {
+			// Use GetOrSet with lock disabled? We want one worker to refresh: use lockEnabled true
+			_, err := GetOrSet(ctx, key, loader, append(opts, WithAsync(false))...)
+			if err != nil {
+				zap.L().Error("cachex: background refresh failed", zap.String("full", full), zap.String("err", err.Error()))
+			}
+		}()
+		return &out, nil
+	}
+
+	// 未命中则走普通 GetOrSet(带锁)
+	return GetOrSet(ctx, key, loader, opts...)
+}

+ 1 - 0
service/cmd/showLog.sh

@@ -0,0 +1 @@
+journalctl -u fwh-toolbox-server -f

+ 92 - 0
service/controllers/auth.go

@@ -0,0 +1,92 @@
+package controllers
+
+import (
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+)
+
+var (
+	// session cookie name
+	sessionCookieName = "sid"
+)
+
+func Login(c *fiber.Ctx) error {
+	var body dto.LoginParam
+	if err := c.BodyParser(&body); err != nil {
+		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+			"code":    400,
+			"message": "Invalid request body",
+		})
+	}
+
+	auth, err := services.Login(body)
+	if err != nil {
+		zap.L().Error("登录请求错误。", zap.String("err", err.Error()))
+		return JSONResponseNoData(c, 500, "Login error. (casdoor)")
+	}
+
+	// 设置 HttpOnly cookie 保存 sid
+	c.Cookie(&fiber.Cookie{
+		Name:     sessionCookieName,
+		Value:    auth.SessionId,
+		Expires:  time.Now().Add(domain.SessionTTL),
+		HTTPOnly: true,
+		Secure:   false, // production: true
+		SameSite: "Lax",
+		Path:     "/fwh-toolbox/",
+	})
+
+	return JSONResponse(c, 0, "success", auth.AccessToken)
+}
+
+func GetSession(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	if sid == "" {
+		return c.Status(403).JSON(fiber.Map{"error": "no session"})
+	}
+	res, err := services.GetSession(sid)
+	if err != nil {
+		zap.L().Error("读取会话信息失败。", zap.String("err", err.Error()))
+		return JSONResponseNoData(c, 403, "Get session error. (casdoor)")
+	}
+	return JSONResponse(c, 0, "success", res)
+}
+
+func RefreshToken(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	if sid == "" {
+		return c.Status(403).JSON(fiber.Map{"error": "no session"})
+	}
+	res, err := services.RefreshToken(sid)
+	if err != nil {
+		zap.L().Error("刷新Token失败。", zap.String("err", err.Error()))
+		return JSONResponseNoData(c, 403, "Refresh token error. (casdoor)")
+	}
+	return JSONResponse(c, 0, "success", res)
+}
+
+func Logout(c *fiber.Ctx) error {
+	sid := c.Cookies(sessionCookieName)
+	err := services.Logout(sid)
+	if err != nil {
+		zap.L().Error("登出请求失败。", zap.String("err", err.Error()))
+		return JSONResponseNoData(c, 500, "Logout error. (casdoor)")
+	}
+
+	// 清除 cookie
+	c.Cookie(&fiber.Cookie{
+		Name:     sessionCookieName,
+		Value:    "",
+		Expires:  time.Now().Add(-time.Hour),
+		HTTPOnly: true,
+		Secure:   false,
+		Path:     "/fwh-toolbox/",
+	})
+
+	return c.SendStatus(fiber.StatusNoContent)
+}

+ 0 - 0
service/controllers/public.go → service/controllers/response.go


+ 14 - 0
service/domain/authDomain.go

@@ -0,0 +1,14 @@
+package domain
+
+import "time"
+
+var (
+	// session TTL (sync with refresh token validity; e.g. 7 days)
+	SessionTTL = 7 * 24 * time.Hour
+)
+
+type AuthInfo struct {
+	SessionId    string
+	AccessToken  string
+	RefreshToken string
+}

+ 6 - 0
service/dto/authDTO.go

@@ -0,0 +1,6 @@
+package dto
+
+type LoginParam struct {
+	Code  string `json:"code"`
+	State string `json:"state"`
+}

+ 13 - 9
service/go.mod

@@ -5,15 +5,26 @@ go 1.23.0
 toolchain go1.24.5
 
 require (
-	github.com/go-redis/redis/v8 v8.11.5
+	github.com/bwmarrin/snowflake v0.3.0
+	github.com/casdoor/casdoor-go-sdk v1.21.0
 	github.com/gofiber/fiber/v2 v2.52.9
 	github.com/gofiber/swagger v1.1.1
+	github.com/golang-jwt/jwt/v5 v5.3.0
+	github.com/google/uuid v1.6.0
+	github.com/redis/go-redis/v9 v9.14.0
 	github.com/robfig/cron/v3 v3.0.1
+	github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
 	github.com/spf13/viper v1.20.1
+	github.com/tidwall/evio v1.0.8
+	go.mongodb.org/mongo-driver v1.17.4
 	go.uber.org/zap v1.27.0
+	golang.org/x/oauth2 v0.25.0
+	gorm.io/datatypes v1.2.6
 	gorm.io/driver/mysql v1.6.0
 	gorm.io/driver/postgres v1.6.0
+	gorm.io/gen v0.3.27
 	gorm.io/gorm v1.30.1
+	gorm.io/plugin/dbresolver v1.6.2
 )
 
 require (
@@ -22,7 +33,6 @@ require (
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
-	github.com/bwmarrin/snowflake v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/fsnotify/fsnotify v1.8.0 // indirect
@@ -32,8 +42,8 @@ require (
 	github.com/go-openapi/swag v0.19.15 // indirect
 	github.com/go-sql-driver/mysql v1.9.3 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
-	github.com/google/uuid v1.6.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
 	github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -51,7 +61,6 @@ require (
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
-	github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 // indirect
 	github.com/sourcegraph/conc v0.3.0 // indirect
 	github.com/spf13/afero v1.12.0 // indirect
 	github.com/spf13/cast v1.7.1 // indirect
@@ -59,7 +68,6 @@ require (
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/swaggo/files/v2 v2.0.2 // indirect
 	github.com/swaggo/swag v1.16.4 // indirect
-	github.com/tidwall/evio v1.0.8 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.51.0 // indirect
 	github.com/valyala/tcplisten v1.0.0 // indirect
@@ -67,7 +75,6 @@ require (
 	github.com/xdg-go/scram v1.1.2 // indirect
 	github.com/xdg-go/stringprep v1.0.4 // indirect
 	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
-	go.mongodb.org/mongo-driver v1.17.4 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/crypto v0.41.0 // indirect
 	golang.org/x/mod v0.27.0 // indirect
@@ -78,8 +85,5 @@ require (
 	golang.org/x/tools v0.36.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	gorm.io/datatypes v1.2.6 // indirect
-	gorm.io/gen v0.3.27 // indirect
 	gorm.io/hints v1.1.2 // indirect
-	gorm.io/plugin/dbresolver v1.6.2 // indirect
 )

+ 24 - 24
service/go.sum

@@ -8,8 +8,14 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
 github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
 github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
+github.com/casdoor/casdoor-go-sdk v1.21.0 h1:EgDhM1WGmYEqG0Pa9PPMkyAISQkrSR6+mrTsL+QZ2Bg=
+github.com/casdoor/casdoor-go-sdk v1.21.0/go.mod h1:cMnkCQJgMYpgAlgEx8reSt1AVaDIQLcJ1zk5pzBaz+4=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -32,8 +38,6 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7
 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
 github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
 github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
-github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
-github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
 github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
 github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
@@ -42,6 +46,14 @@ github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5
 github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
 github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA=
 github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw=
+github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
+github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -85,20 +97,19 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
 github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
+github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
 github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
-github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
-github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
-github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
-github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
-github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
+github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -152,35 +163,27 @@ go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFX
 go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
-go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
-golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
 golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
 golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
 golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
 golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
+golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
 golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -200,15 +203,11 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
 golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
 golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -217,8 +216,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@@ -232,7 +229,10 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
 gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
 gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
 gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/driver/sqlite v1.5.0 h1:zKYbzRCpBrT1bNijRnxLDJWPjVfImGEn0lSnUY5gZ+c=
 gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
+gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
+gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
 gorm.io/gen v0.3.27 h1:ziocAFLpE7e0g4Rum69pGfB9S6DweTxK8gAun7cU8as=
 gorm.io/gen v0.3.27/go.mod h1:9zquz2xD1f3Eb/eHq4oLn2z6vDVvQlCY5S3uMBLv4EA=
 gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=

+ 4 - 0
service/main.go

@@ -5,6 +5,7 @@ import (
 	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/database"
 	"fwh-toolbox-server/taskQueue"
+	"fwh-toolbox-server/utils/casdoor"
 
 	// "fwh-toolbox-server/database"
 	_ "fwh-toolbox-server/docs" // swag 自动生成文档
@@ -48,6 +49,9 @@ func main() {
 	// 初始化任务队列
 	taskQueue.InitTaskQueue()
 
+	// 初始化Casdoor
+	casdoor.InitAuthConfig()
+
 	app := fiber.New()
 	app.Use(func(c *fiber.Ctx) error {
 		logger.Log.Info("请求", zap.String("path", c.Path()))

+ 63 - 0
service/middleware/auth.go

@@ -0,0 +1,63 @@
+package middleware
+
+import (
+	"strings"
+
+	"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+	"github.com/gofiber/fiber/v2"
+)
+
+// AuthMiddleware 认证中间件
+// 可选参数 requiredRoles:若不为空,将检查用户角色是否包含其中之一
+func AuthMiddleware(requiredRoles ...string) fiber.Handler {
+	return func(c *fiber.Ctx) error {
+		authHeader := c.Get("Authorization")
+		if authHeader == "" {
+			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+				"code": 401,
+				"msg":  "Missing Authorization header",
+			})
+		}
+
+		// 解析 Bearer Token
+		tokenString := strings.TrimPrefix(authHeader, "Bearer ")
+		if tokenString == authHeader {
+			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+				"code": 401,
+				"msg":  "Invalid token format",
+			})
+		}
+
+		// 验证 JWT & 提取 claims
+		claims, err := casdoorsdk.ParseJwtToken(tokenString)
+		if err != nil {
+			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
+				"code": 401,
+				"msg":  "Invalid or expired token",
+			})
+		}
+
+		// 检查角色(可选)
+		if len(requiredRoles) > 0 {
+			for _, userRole := range claims.User.Roles {
+				authorized := false
+				for _, r := range requiredRoles {
+					if userRole.Name == r {
+						authorized = true
+						break
+					}
+				}
+				if !authorized {
+					return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
+						"code": 403,
+						"msg":  "Forbidden: insufficient role",
+					})
+				}
+			}
+		}
+
+		// 注入用户信息
+		c.Locals("user", claims)
+		return c.Next()
+	}
+}

+ 70 - 0
service/repositories/redis/authRepo.go

@@ -0,0 +1,70 @@
+package redis
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"strconv"
+	"time"
+
+	"golang.org/x/oauth2"
+)
+
+var (
+	ctx = context.Background()
+)
+
+type SessionInfo struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	AccessExp    int64  `json:"access_exp"`
+}
+
+func StoreSession(sid string, token *oauth2.Token) error {
+	key := fmt.Sprintf("session:%s", sid)
+	exp := time.Now().Add(domain.SessionTTL)
+	accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
+	fields := map[string]interface{}{
+		"refresh_token": token.RefreshToken,
+		"access_token":  token.AccessToken,
+		"access_exp":    accessExp,
+	}
+	if err := cache.HSet(ctx, key, fields); err != nil {
+		return err
+	}
+	_, err := cache.ExpireAt(ctx, key, exp)
+	return err
+}
+
+func GetSession(sid string) (*SessionInfo, error) {
+	key := fmt.Sprintf("session:%s", sid)
+	res, err := cache.HGetAll(ctx, key)
+	if err != nil {
+		return nil, err
+	}
+	if len(res) == 0 {
+		return nil, errors.New("no session")
+	}
+	accessToken := res["access_token"]
+	refreshToken := res["refresh_token"]
+	accessExpStr := res["access_exp"]
+	var accessExp int64
+	if accessExpStr != "" {
+		accessExp, err = strconv.ParseInt(accessExpStr, 10, 64)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return &SessionInfo{
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+		AccessExp:    accessExp,
+	}, nil
+}
+
+func DeleteSession(sid string) error {
+	key := fmt.Sprintf("session:%s", sid)
+	return cache.Delete(ctx, key)
+}

+ 10 - 0
service/repositories/repositories.go

@@ -4,6 +4,7 @@ import (
 	"fwh-toolbox-server/repositories/api/fwh_pro"
 	"fwh-toolbox-server/repositories/mongo"
 	"fwh-toolbox-server/repositories/pgsql/alarmSnapshot"
+	"fwh-toolbox-server/repositories/redis"
 )
 
 // 聚合包
@@ -63,3 +64,12 @@ var InsertSnapshotFileRecord = alarmSnapshot.InsertSnapshotFileRecord
 var InsertSnapshotData = alarmSnapshot.InsertSnapshotData
 
 /*************** MYSQL ***************/
+
+/*************** REDIS ***************/
+
+// Auth
+type SessionInfo = redis.SessionInfo
+
+var StoreSession = redis.StoreSession
+var GetSession = redis.GetSession
+var DeleteSession = redis.DeleteSession

+ 30 - 0
service/routes/authRoutes.go

@@ -0,0 +1,30 @@
+/*
+ * Auth API List
+ * /auth/login - POST 登录
+ * /auth/session - GET 恢复会话
+ * /auth/refresh - POST 刷新Token
+ * /auth/logout - POST 登出
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func AuthSetup(api fiber.Router) {
+	// 登录
+	api.Post("/api/auth/login", controllers.Login)
+
+	// 恢复会话
+	api.Get("/api/auth/session", controllers.GetSession)
+
+	// 刷新Token
+	api.Post("/api/auth/refresh", controllers.RefreshToken)
+}
+
+func LogoutSetup(api fiber.Router) {
+	// 登出
+	api.Post("/auth/logout", controllers.Logout)
+}

+ 11 - 1
service/routes/routes.go

@@ -2,13 +2,20 @@ package routes
 
 import (
 	_ "fwh-toolbox-server/docs"
+	"fwh-toolbox-server/middleware"
 
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/swagger"
 )
 
 func Setup(app *fiber.App) {
-	api := app.Group("/api")
+	// 无需验证
+	AuthSetup(app)
+
+	api := app.Group("/api", middleware.AuthMiddleware())
+
+	LogoutSetup(api)
+
 	// api.Get("/keyAccounts/sunrun/state", controllers.GetUsers)
 	// api.Get("/keyAccounts/sunrun/abnormalList", controllers.GetUsers)
 	// …
@@ -17,4 +24,7 @@ func Setup(app *fiber.App) {
 	XlogSetup(api)
 
 	app.Get("/swagger/*", swagger.HandlerDefault)
+
+	// simple health
+	app.Get("/ping", func(c *fiber.Ctx) error { return c.SendString("pong") })
 }

+ 106 - 0
service/services/authServices.go

@@ -0,0 +1,106 @@
+package services
+
+import (
+	"errors"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/utils/casdoor"
+	"time"
+
+	"github.com/google/uuid"
+	"go.uber.org/zap"
+)
+
+// 请求登录
+func Login(param dto.LoginParam) (*domain.AuthInfo, error) {
+	auth, err := casdoor.Signin(param.Code, param.State)
+	if err != nil {
+		return nil, err
+	}
+
+	// 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存)
+	sid := uuid.NewString()
+	if err := repositories.StoreSession(sid, auth); err != nil {
+		return nil, fmt.Errorf("store session err: %w", err)
+	}
+
+	return &domain.AuthInfo{
+		SessionId:    sid,
+		AccessToken:  auth.AccessToken,
+		RefreshToken: auth.RefreshToken,
+	}, nil
+}
+
+// 恢复会话 - 返回AccessToken
+func GetSession(sid string) (string, error) {
+	sess, err := repositories.GetSession(sid)
+	if err != nil {
+		return "", errors.New("session not found")
+	}
+	at := sess.AccessToken
+	if !isExpired(at) {
+		// 证书过期
+		rt := sess.RefreshToken
+		if !isExpired(rt) {
+			// 刷新证书过期,返回空证书
+			return "", nil
+		}
+
+		// 刷新证书
+		at, err = RefreshToken(sid)
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return at, nil
+}
+
+// 刷新Token - 返回AccessToken
+func RefreshToken(sid string) (string, error) {
+	sess, err := repositories.GetSession(sid)
+	if err != nil {
+		return "", errors.New("session not found")
+	}
+	rt := sess.RefreshToken
+	if rt == "" {
+		return "", errors.New("no refresh token")
+	}
+
+	auth, err := casdoor.RefreshToken(rt)
+	if err != nil {
+		return "", err
+	}
+
+	if err := repositories.StoreSession(sid, auth); err != nil {
+		return "", fmt.Errorf("store session err: %w", err)
+	}
+
+	return auth.AccessToken, nil
+}
+
+// 请求登出
+func Logout(sid string) error {
+	if sid != "" {
+		return repositories.DeleteSession(sid)
+	}
+
+	return nil
+}
+
+// 检查证书是否过期
+// true - 未过期
+// false - 过期
+func isExpired(token string) bool {
+	claims, err := casdoor.ParseJwtToken(token)
+	if err != nil {
+		zap.L().Error("ParseJwtToken() error", zap.String("err", err.Error()))
+		return false
+	}
+	if claims.ExpiresAt == nil {
+		return false // 没有设置过期时间
+	}
+	return claims.ExpiresAt.Time.Before(time.Now())
+}

+ 8 - 19
service/taskQueue/taskQueue.go

@@ -9,12 +9,11 @@ import (
 	"fwh-toolbox-server/logger"
 	"time"
 
-	"github.com/go-redis/redis/v8"
+	"github.com/redis/go-redis/v9"
 )
 
 var (
 	ctx        = context.Background()
-	rdb        = cache.C
 	streamName = "fwhtoolbox_stream"
 	groupName  = "task_queue"
 	consumerID = "worker-taskQueue"
@@ -37,11 +36,8 @@ type TaskPayload[T any] struct {
 }
 
 func InitTaskQueue() {
-	// 刷新redis指针
-	rdb = cache.C
-
 	// 确保消费组存在(忽略已存在错误)
-	_ = rdb.XGroupCreateMkStream(ctx, streamName, groupName, "$").Err()
+	_ = cache.XGroupCreate(ctx, streamName, groupName, "$")
 
 	// 启动后台任务队列(不会阻塞主线程)
 	go startTaskQueueWorker()
@@ -49,14 +45,11 @@ func InitTaskQueue() {
 
 // 任务消费者(单 worker,顺序执行)
 func startTaskQueueWorker() {
+	streamsMap := make(map[string]string)
+	streamsMap[streamName] = ">"
+
 	for {
-		res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
-			Group:    groupName,
-			Consumer: consumerID,
-			Streams:  []string{streamName, ">"},
-			Count:    1,
-			Block:    5 * time.Second, // 等待任务
-		}).Result()
+		res, err := cache.XReadGroup(ctx, groupName, consumerID, streamsMap, 1) // 等待任务
 
 		if err != nil {
 			if err == redis.Nil {
@@ -125,7 +118,7 @@ func startTaskQueueWorker() {
 func ackTask(streamInfo TaskStreamInfo, success bool, err error) {
 	if success {
 		// 确认任务完成
-		if err := rdb.XAck(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID).Err(); err != nil {
+		if err := cache.XAck(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
 			logger.Std.Println("XAck error:", err)
 		}
 	} else {
@@ -140,11 +133,7 @@ func EnqueueTask[T any](payload TaskPayload[T]) error {
 		return err
 	}
 
-	fmt.Println(rdb)
-	if _, err := rdb.XAdd(ctx, &redis.XAddArgs{
-		Stream: streamName,
-		Values: map[string]interface{}{"payload": string(data)},
-	}).Result(); err != nil {
+	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data)}); err != nil {
 		return err
 	}
 

+ 88 - 0
service/utils/casdoor/casdoor.go

@@ -0,0 +1,88 @@
+package casdoor
+
+import (
+	"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+	"go.uber.org/zap"
+	"golang.org/x/oauth2"
+)
+
+const (
+	endpoint     string = "https://raspberrypi.dev/casdoor"
+	clientId     string = "f7059ed03570512cc79c"
+	clientSecret string = "437e687903cce3d4aef50f5ab14f863faba41cad"
+	certificate  string = `
+-----BEGIN CERTIFICATE-----
+MIIE3TCCAsWgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCgxDjAMBgNVBAoTBWFk
+bWluMRYwFAYDVQQDEw1jZXJ0LWJ1aWx0LWluMB4XDTI1MDUwNzA5NDMwN1oXDTQ1
+MDUwNzA5NDMwN1owKDEOMAwGA1UEChMFYWRtaW4xFjAUBgNVBAMTDWNlcnQtYnVp
+bHQtaW4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC6bhrTnADekQLx
+KtKiOqehyevrHzXKvBykfjwO6k0oLV9VSMntki2FNASDYTs3eFtG7wqPJoWJuDPC
++XISWIwKc0Bc8trIeQkSiJOvLWF57wOX0HlxvGd6ooRrPe6yAHjMcMtNP5s+DzpO
+qfOIOCH9oOFBxHN02VIRoo+ACflI9mkS6TRIAJ8fG5L631qulyYwyq3TJ6vaBxte
+1ZWV0Fh72VD3FaME9pmHdv3IcnX6U2pvwZoN+jSOiaTSCI15+5PFsC3av1k5ZJTH
++Yp7okwVvQRJbwOwM4Lr3eQE7tcUVxLbstVKwhsR2EqtVHRlt+r0SUE72aASaM3m
+iWZS8lKJIJxbSRgtf3zOkpZR4zIBU3zdB9OoHykxLlbg4POOLloWMsdVflXbWzRt
+Abwo5SSsMq+Yr7+E4EB6ricXz1OvUPELEZa1c6QhTUzeBhrV5ro/QNNN32HCFgu4
+ivLxijF7FFGdxFykUArWxEd+d5+lCuHn8lDxYGBvp65G36f8knyOtN4K02cFoNtY
+5l+s9yqkyELfw8it0bkxO609KZd9Xkp3wUloBMdiJ7UxUNIVS0bpL7wkwt/CzPdM
+Bm/RAPzkjazKgireWZ0cKv3QfYu4+QC8E7QeXI9Oqm6YCSMPsGJBOM9arA7xZdUc
+JZnNstHWHrFihbY1nG6WQwT8ndMYpwIDAQABoxAwDjAMBgNVHRMBAf8EAjAAMA0G
+CSqGSIb3DQEBCwUAA4ICAQAri/3HicFl5pu3B2udfdVgxmgUmf1nvPFhuiV7hgIP
+5CVHe2IiDPJYGL55NNqYQpRLTE7Egnz6TMRiVuYKYBHLM2Tb133GpTvXntiwb+D/
+FSha4clBJBBHlx7lX8xGy+FIwlWQQzB/O8S1hbRi869Kb91QFIRgbRsW6Bt+0Y+9
+OtilWi5scqV28Lt2XyzxsQgsYAu0VOSxFs8Rk1WIvAAciefiPMGAlIpsm52c/0H+
+s1LqdPLoj+GF3/iMOd4EIRFGiH3ntdBqYjvf39w90uNn0uWBnVdyu8IsbNU92v84
+jdE7sTYMD74Q9rt9JH/dr2Z4ufjRSn6x7mRNxw3oPnMd5qdbB1ux8j5zrJAARRrQ
+QKju4s5SB+6WWjALhoMfO7N0wIqoKDleoaGxUj1r4AX9P+wRP0H1W2PV/H0y+el1
+gjpPCfs/IZGgWkSvpHI0b6yikTaCZeAgoBPB2sjTWZyeBnkXEM8BGgVjgaoFUwIl
+OgE9eIWpTKBvPYYellO+hB47j75BnIF7GImDBIwHvEtk7jw3psaLSYGGoNhcG5X9
+xJe+ghCvXu1cz5mvUxMIrUsbUL4zSCsJpkzQQQ+95ZJroGsl6dwewMY8hYhXo7wJ
+EikVj20dYSjAC2azqGic6fIDosE+qdT8zzCkmEOJQg42D1eRl+E2KmIThFpIyNp/
+Vw==
+-----END CERTIFICATE-----
+`
+	organizationName string = "organization_franklinwh_toolbox"
+	applicationName  string = "app-franklinwh-toolbox"
+)
+
+func InitAuthConfig() {
+	casdoorsdk.InitConfig(
+		endpoint,
+		clientId,
+		clientSecret,
+		certificate,
+		organizationName,
+		applicationName,
+	)
+}
+
+func Signin(code, state string) (*oauth2.Token, error) {
+	token, err := casdoorsdk.GetOAuthToken(code, state)
+	if err != nil {
+		zap.L().Error("GetOAuthToken() error", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	return token, nil
+}
+
+func RefreshToken(refreshToken string) (*oauth2.Token, error) {
+	token, err := casdoorsdk.RefreshOAuthToken(refreshToken)
+	if err != nil {
+		zap.L().Error("GetOAuthToken() error", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	return token, nil
+}
+
+type CasdoorClaims = casdoorsdk.Claims
+
+func ParseJwtToken(token string) (*CasdoorClaims, error) {
+	claims, err := casdoorsdk.ParseJwtToken(token)
+	if err != nil {
+		return nil, err
+	}
+	claims.AccessToken = token
+	return claims, nil
+}

+ 4 - 0
src/main.ts

@@ -14,9 +14,13 @@ import warnHandler from '@/utils/warnHandler'
 
 import { InitStores } from '@/stores/initStores'
 
+import { setupCasdoor } from './utils/casdoor/casdoor'
+
 async function setupApp() {
   const app = createApp(App)
 
+  setupCasdoor(app)
+
   app.use(pinia)
   app.use(router)
 

+ 21 - 0
src/router/guard.ts

@@ -6,6 +6,8 @@ import { useUserStore } from '@/stores/user'
 
 import type { Router } from 'vue-router'
 
+import { login } from '@/service/auth'
+
 const Layout = () => import('@/layout/index.vue')
 
 const { loadingBar } = useDiscreteApi()
@@ -15,6 +17,8 @@ const configureStore = useConfigureStore(createPinia())
 export function setupRouterGuard(router: Router) {
   router.beforeEach(async (to, from, next) => {
     console.log({ to, from })
+    console.log(to.name)
+    console.log(to.path)
     configureStore.setNavigating(true)
 
     if (configureStore.configure.showTopLoadingBar) {
@@ -23,6 +27,23 @@ export function setupRouterGuard(router: Router) {
 
     const userStore = useUserStore()
 
+    if (to.name === 'cassdoor-callback') {
+      const code = to.query.code
+      const state = to.query.state
+      if (typeof code === 'string' && typeof state === 'string') {
+        // 登录获取Token
+        const res = await login({ code, state })
+        // 保存token
+        userStore.setToken(res)
+
+        next(from.fullPath)
+      } else {
+        // 🚨 登录参数错误时跳转到 errorPage
+        next({ name: 'errorPage' })
+      }
+      return
+    }
+
     if (to.name === 'signin') {
       if (!userStore.token) {
         next()

+ 15 - 3
src/router/index.ts

@@ -1,11 +1,23 @@
 import { createRouter, createWebHistory } from 'vue-router'
-
 import { setupRouterGuard } from './guard'
-
 import type { RouteRecordRaw } from 'vue-router'
+import { jumpToSignin } from '@/utils/casdoor/casdoor'
 
 const routes: RouteRecordRaw[] = [
-  { path: '/sign-in', name: 'signin', component: () => import('@/views/sign-in/index.vue') },
+  // { path: '/sign-in', name: 'signin', component: () => import('@/views/sign-in/index.vue') },
+  {
+    path: '/sign-in',
+    name: 'signin',
+    component: defineComponent({ template: '<div>正在跳转登录页...</div>' }), // 占位组件
+    beforeEnter() {
+      jumpToSignin()
+    },
+  },
+  {
+    path: '/casdoor/callback',
+    name: 'cassdoor-callback',
+    component: defineComponent({ template: '<div>登录中...</div>' }), // 占位组件
+  },
   {
     name: 'errorPage',
     path: '/:pathMatch(.*)*',

+ 47 - 0
src/service/auth/index.ts

@@ -0,0 +1,47 @@
+// API List
+// /auth/login - POST 登录
+// /auth/session - GET 恢复会话
+// /auth/refresh - POST 刷新Token
+// /auth/logout - POST 登出
+
+import http from '@/service/serverReq'
+import type { LoginParam } from './interface'
+
+const API_List = {
+  login: '/auth/login',
+  session: '/auth/session',
+  refreshToken: '/auth/refresh',
+  logout: '/auth/logout',
+}
+
+// POST 请求登录
+// return Token
+export const login = async (param: LoginParam): Promise<string> => {
+  const url = API_List.login
+  const res = await http.post<string>(url, param)
+  return res
+}
+
+// GET 恢复会话
+// return Token
+export const session = async (): Promise<string> => {
+  const url = API_List.session
+  const res = await http.get<string>(url)
+  return res
+}
+
+// POST 刷新Token
+// return Token
+export const refreshToken = async (): Promise<string> => {
+  const url = API_List.refreshToken
+  const res = await http.post<string>(url)
+  return res
+}
+
+// POST 请求登出
+// return boolean
+export const logout = async (): Promise<boolean> => {
+  const url = API_List.logout
+  const res = await http.post<boolean>(url)
+  return res
+}

+ 4 - 0
src/service/auth/interface.ts

@@ -0,0 +1,4 @@
+export interface LoginParam {
+  code: string
+  state: string
+}

+ 69 - 28
src/service/fetchClient.ts

@@ -13,13 +13,27 @@ export interface FetchOptions extends RequestInit {
   key?: string // 用于实现请求去重
 }
 
-export type Interceptor<T> = (data: T) => T | Promise<T>
+export type FetchResponse<T = any> =
+  | {
+      status: number
+      ok: true
+      data: T
+    }
+  | {
+      status: number
+      ok: false
+      data: string
+      retry?: () => Promise<FetchResponse<T>>
+    }
+
+export type RequestInterceptor<T> = (data: T) => T | Promise<T>
+export type ResponseInterceptor<T> = (data: FetchResponse<T>) => T | Promise<T>
 
 export class FetchClient {
   private baseURL: string = ''
   private defaultTimeout = 10000
-  private requestInterceptor?: Interceptor<RequestInit>
-  private responseInterceptor?: Interceptor<any>
+  private requestInterceptor?: RequestInterceptor<RequestInit>
+  private responseInterceptor?: ResponseInterceptor<any>
 
   constructor(baseURL = '', timeout = 10000) {
     this.baseURL = baseURL
@@ -34,21 +48,21 @@ export class FetchClient {
     this.defaultTimeout = timeout
   }
 
-  setRequestInterceptor(interceptor: Interceptor<RequestInit>) {
+  setRequestInterceptor(interceptor: RequestInterceptor<RequestInit>) {
     this.requestInterceptor = interceptor
   }
 
-  setResponseInterceptor(interceptor: Interceptor<any>) {
+  setResponseInterceptor(interceptor: ResponseInterceptor<any>) {
     this.responseInterceptor = interceptor
   }
 
-  async request<T = any>(
+  async request<D = any>(
     url: string,
     method: HttpMethod = 'GET',
     options: FetchOptions = {},
     queue?: TaskQueue, // 指定请求队列
     key?: string, // 用于实现请求去重
-  ): Promise<T> {
+  ): Promise<D> {
     const {
       baseURL = this.baseURL,
       timeout = this.defaultTimeout,
@@ -88,21 +102,48 @@ export class FetchClient {
     const finalConfig = this.requestInterceptor ? await this.requestInterceptor(config) : config
 
     try {
-      let queuePromise: Promise<T>
+      let queuePromise: Promise<D>
       const fetchHandle = async () => {
         const res = await fetch(fullURL, finalConfig)
-        if (!res.ok) {
-          const errorBody = await res.text()
-          throw new Error(`HTTP ${res.status}: ${errorBody}`)
-        }
-
         const contentType = res.headers.get('Content-Type') || ''
-        let result: any
-        if (contentType.includes('application/json')) {
-          result = await res.json()
-        } else {
-          result = await res.text()
-        }
+        const data =
+          res.ok && contentType.includes('application/json') ? await res.json() : await res.text()
+
+        const result: FetchResponse<any> = res.ok
+          ? {
+              status: res.status,
+              ok: res.ok,
+              data: data,
+            }
+          : {
+              status: res.status,
+              ok: res.ok,
+              data: data as string,
+              retry: async () => {
+                console.log('retry')
+                // 请求拦截器
+                const finalConfig = this.requestInterceptor
+                  ? await this.requestInterceptor(config)
+                  : config
+
+                console.log({ finalConfig })
+
+                const res = await fetch(fullURL, finalConfig)
+                const contentType = res.headers.get('Content-Type') || ''
+                const data =
+                  res.ok && contentType.includes('application/json')
+                    ? await res.json()
+                    : await res.text()
+
+                const result: FetchResponse<D> = {
+                  status: res.status,
+                  ok: res.ok,
+                  data,
+                }
+
+                return this.responseInterceptor ? await this.responseInterceptor(result) : result
+              },
+            }
 
         return this.responseInterceptor ? await this.responseInterceptor(result) : result
       }
@@ -112,7 +153,7 @@ export class FetchClient {
       } else {
         queuePromise = fetchHandle()
       }
-      
+
       const responseData = await queuePromise
       clearTimeout(timeoutId)
 
@@ -125,20 +166,20 @@ export class FetchClient {
     }
   }
 
-  get<T = any>(url: string, options?: FetchOptions) {
-    return this.request<T>(url, 'GET', options, options?.queue, options?.key)
+  get<D = any>(url: string, options?: FetchOptions) {
+    return this.request<D>(url, 'GET', options, options?.queue, options?.key)
   }
 
-  post<T = any>(url: string, data?: any, options?: FetchOptions) {
-    return this.request<T>(url, 'POST', { ...options, data }, options?.queue, options?.key)
+  post<D = any>(url: string, data?: any, options?: FetchOptions) {
+    return this.request<D>(url, 'POST', { ...options, data }, options?.queue, options?.key)
   }
 
-  put<T = any>(url: string, data?: any, options?: FetchOptions) {
-    return this.request<T>(url, 'PUT', { ...options, data }, options?.queue, options?.key)
+  put<D = any>(url: string, data?: any, options?: FetchOptions) {
+    return this.request<D>(url, 'PUT', { ...options, data }, options?.queue, options?.key)
   }
 
-  delete<T = any>(url: string, options?: FetchOptions) {
-    return this.request<T>(url, 'DELETE', options, options?.queue, options?.key)
+  delete<D = any>(url: string, options?: FetchOptions) {
+    return this.request<D>(url, 'DELETE', options, options?.queue, options?.key)
   }
 }
 

+ 95 - 6
src/service/serverReq.ts

@@ -1,16 +1,83 @@
 import { v4 as uuid_v4 } from 'uuid'
-import { FetchClient } from './fetchClient'
+import { FetchClient, type FetchResponse } from './fetchClient'
+import { useUserStore } from '@/stores/user'
+import { jumpToSignin } from '@/utils/casdoor/casdoor'
+import { refreshToken } from './auth'
 
 const baseUrl = import.meta.env.BASE_URL
 
+export interface Response<T = any> {
+  code: number
+  message: string
+  data: T
+  traceId: string
+  timestamp: number
+}
+
 const http = new FetchClient(baseUrl + 'api')
 
-console.log({ baseUrl })
+let isRefreshing = false
+let pendingQueue: (() => void)[] = []
+
+async function handleTokenExpired(
+  response: FetchResponse<Response<any>>,
+): Promise<FetchResponse<Response<any>>> {
+  if (!('retry' in response)) {
+    return response
+  }
+
+  // 原始请求包装成函数
+  const originalRequest = response.retry
+  if (originalRequest === undefined) {
+    return response
+  }
+
+  // 如果已经在刷新,挂起请求
+  if (isRefreshing) {
+    return new Promise<FetchResponse<Response<any>>>((resolve, reject) => {
+      pendingQueue.push(() => originalRequest().then(resolve).catch(reject))
+    })
+  }
+
+  isRefreshing = true
+
+  try {
+    // 调用刷新接口
+    const res = await refreshToken()
+
+    const userStore = useUserStore()
+    userStore.setToken(res)
+
+    // 刷新完成,重放队列请求
+    await Promise.all(pendingQueue.map((fn) => fn()))
+    pendingQueue = []
+
+    // 重新执行当前请求
+    return originalRequest()
+  } catch (err) {
+    handleRefreshFailed()
+    throw err
+  } finally {
+    isRefreshing = false
+  }
+}
+
+function handleRefreshFailed() {
+  const userStore = useUserStore()
+  userStore.setToken(null)
+  pendingQueue = []
+  isRefreshing = false
+
+  // 跳转登录页
+  jumpToSignin()
+}
 
 // 设置拦截器(可选)
 http.setRequestInterceptor((config) => {
+  const userStore = useUserStore()
   // 自动加 token
-  const token = localStorage.getItem('token')
+  const token = userStore.token
+  console.log({ token })
   if (token) {
     config.headers = {
       ...config.headers,
@@ -21,10 +88,32 @@ http.setRequestInterceptor((config) => {
   return config
 })
 
-http.setResponseInterceptor((response) => {
+http.setResponseInterceptor(async (response: FetchResponse<Response<any>>): Promise<any> => {
   // 全局响应处理,比如统一格式校验
-  if (response.code !== 0) throw new Error(response.message)
-  return response.data
+  if (!response.ok) {
+    console.log({ response })
+    switch (response.status) {
+      case 401: // Unauthorized Token过期
+        // 刷新Token
+        response = await handleTokenExpired(response)
+        if (!response.ok) {
+          console.log({ response })
+          throw new Error(response.data)
+        }
+        break
+      case 403: // Forbidden 刷新Token失败
+        // 重新登录
+        handleRefreshFailed()
+        break
+      case 204: // No Content
+        break
+      default:
+        console.log({ response })
+        throw new Error(response.data)
+    }
+    return response.data
+  }
+  return response.data.data
 })
 
 export default http

+ 11 - 9
src/stores/user.ts

@@ -39,15 +39,17 @@ export const useUserStore = defineStore('userStore', () => {
 
   async function resolveMenuList() {
     const res = await new Promise<MergeMenuMixedOptions[]>((resolve) => {
-      if (token.value?.includes('admin')) {
-        resolve(routeRecordRaw)
-      } else {
-        const allowedRoutes = ['dashboard', 'dataShow', 'notfoundPage', 'about']
-        const filteredRoutes = routeRecordRaw.filter((route) => {
-          return !route.type && route.name && allowedRoutes.includes(route.name as string)
-        })
-        resolve(filteredRoutes)
-      }
+      // if (token.value?.includes('admin')) {
+      //   resolve(routeRecordRaw)
+      // } else {
+      //   const allowedRoutes = ['dashboard', 'dataShow', 'notfoundPage', 'about']
+      //   const filteredRoutes = routeRecordRaw.filter((route) => {
+      //     return !route.type && route.name && allowedRoutes.includes(route.name as string)
+      //   })
+      //   resolve(filteredRoutes)
+      // }
+      // 暂时没有管理员权限
+      resolve(routeRecordRaw)
     })
 
     menuList.value = resolveMenu(res) || []

+ 63 - 63
src/types/auto-imports.d.ts

@@ -6,69 +6,69 @@
 // biome-ignore lint: disable
 export {}
 declare global {
-  const EffectScope: typeof import('vue')['EffectScope']
-  const computed: typeof import('vue')['computed']
-  const createApp: typeof import('vue')['createApp']
-  const customRef: typeof import('vue')['customRef']
-  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
-  const defineComponent: typeof import('vue')['defineComponent']
-  const effectScope: typeof import('vue')['effectScope']
-  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
-  const getCurrentScope: typeof import('vue')['getCurrentScope']
-  const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
-  const h: typeof import('vue')['h']
-  const inject: typeof import('vue')['inject']
-  const isProxy: typeof import('vue')['isProxy']
-  const isReactive: typeof import('vue')['isReactive']
-  const isReadonly: typeof import('vue')['isReadonly']
-  const isRef: typeof import('vue')['isRef']
-  const isShallow: typeof import('vue')['isShallow']
-  const markRaw: typeof import('vue')['markRaw']
-  const nextTick: typeof import('vue')['nextTick']
-  const onActivated: typeof import('vue')['onActivated']
-  const onBeforeMount: typeof import('vue')['onBeforeMount']
-  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
-  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
-  const onDeactivated: typeof import('vue')['onDeactivated']
-  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
-  const onMounted: typeof import('vue')['onMounted']
-  const onRenderTracked: typeof import('vue')['onRenderTracked']
-  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
-  const onScopeDispose: typeof import('vue')['onScopeDispose']
-  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
-  const onUnmounted: typeof import('vue')['onUnmounted']
-  const onUpdated: typeof import('vue')['onUpdated']
-  const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
-  const provide: typeof import('vue')['provide']
-  const reactive: typeof import('vue')['reactive']
-  const readonly: typeof import('vue')['readonly']
-  const ref: typeof import('vue')['ref']
-  const resolveComponent: typeof import('vue')['resolveComponent']
-  const shallowReactive: typeof import('vue')['shallowReactive']
-  const shallowReadonly: typeof import('vue')['shallowReadonly']
-  const shallowRef: typeof import('vue')['shallowRef']
-  const toRaw: typeof import('vue')['toRaw']
-  const toRef: typeof import('vue')['toRef']
-  const toRefs: typeof import('vue')['toRefs']
-  const toValue: typeof import('vue')['toValue']
-  const triggerRef: typeof import('vue')['triggerRef']
-  const unref: typeof import('vue')['unref']
-  const useAttrs: typeof import('vue')['useAttrs']
-  const useCssModule: typeof import('vue')['useCssModule']
-  const useCssVars: typeof import('vue')['useCssVars']
-  const useDialog: typeof import('naive-ui')['useDialog']
-  const useId: typeof import('vue')['useId']
-  const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
-  const useMessage: typeof import('naive-ui')['useMessage']
-  const useModal: typeof import('naive-ui')['useModal']
-  const useModel: typeof import('vue')['useModel']
-  const useNotification: typeof import('naive-ui')['useNotification']
-  const useSlots: typeof import('vue')['useSlots']
-  const useTemplateRef: typeof import('vue')['useTemplateRef']
-  const watch: typeof import('vue')['watch']
-  const watchEffect: typeof import('vue')['watchEffect']
-  const watchPostEffect: typeof import('vue')['watchPostEffect']
-  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+  const EffectScope: typeof import('vue').EffectScope
+  const computed: typeof import('vue').computed
+  const createApp: typeof import('vue').createApp
+  const customRef: typeof import('vue').customRef
+  const defineAsyncComponent: typeof import('vue').defineAsyncComponent
+  const defineComponent: typeof import('vue').defineComponent
+  const effectScope: typeof import('vue').effectScope
+  const getCurrentInstance: typeof import('vue').getCurrentInstance
+  const getCurrentScope: typeof import('vue').getCurrentScope
+  const getCurrentWatcher: typeof import('vue').getCurrentWatcher
+  const h: typeof import('vue').h
+  const inject: typeof import('vue').inject
+  const isProxy: typeof import('vue').isProxy
+  const isReactive: typeof import('vue').isReactive
+  const isReadonly: typeof import('vue').isReadonly
+  const isRef: typeof import('vue').isRef
+  const isShallow: typeof import('vue').isShallow
+  const markRaw: typeof import('vue').markRaw
+  const nextTick: typeof import('vue').nextTick
+  const onActivated: typeof import('vue').onActivated
+  const onBeforeMount: typeof import('vue').onBeforeMount
+  const onBeforeUnmount: typeof import('vue').onBeforeUnmount
+  const onBeforeUpdate: typeof import('vue').onBeforeUpdate
+  const onDeactivated: typeof import('vue').onDeactivated
+  const onErrorCaptured: typeof import('vue').onErrorCaptured
+  const onMounted: typeof import('vue').onMounted
+  const onRenderTracked: typeof import('vue').onRenderTracked
+  const onRenderTriggered: typeof import('vue').onRenderTriggered
+  const onScopeDispose: typeof import('vue').onScopeDispose
+  const onServerPrefetch: typeof import('vue').onServerPrefetch
+  const onUnmounted: typeof import('vue').onUnmounted
+  const onUpdated: typeof import('vue').onUpdated
+  const onWatcherCleanup: typeof import('vue').onWatcherCleanup
+  const provide: typeof import('vue').provide
+  const reactive: typeof import('vue').reactive
+  const readonly: typeof import('vue').readonly
+  const ref: typeof import('vue').ref
+  const resolveComponent: typeof import('vue').resolveComponent
+  const shallowReactive: typeof import('vue').shallowReactive
+  const shallowReadonly: typeof import('vue').shallowReadonly
+  const shallowRef: typeof import('vue').shallowRef
+  const toRaw: typeof import('vue').toRaw
+  const toRef: typeof import('vue').toRef
+  const toRefs: typeof import('vue').toRefs
+  const toValue: typeof import('vue').toValue
+  const triggerRef: typeof import('vue').triggerRef
+  const unref: typeof import('vue').unref
+  const useAttrs: typeof import('vue').useAttrs
+  const useCssModule: typeof import('vue').useCssModule
+  const useCssVars: typeof import('vue').useCssVars
+  const useDialog: typeof import('naive-ui').useDialog
+  const useId: typeof import('vue').useId
+  const useLoadingBar: typeof import('naive-ui').useLoadingBar
+  const useMessage: typeof import('naive-ui').useMessage
+  const useModal: typeof import('naive-ui').useModal
+  const useModel: typeof import('vue').useModel
+  const useNotification: typeof import('naive-ui').useNotification
+  const useSlots: typeof import('vue').useSlots
+  const useTemplateRef: typeof import('vue').useTemplateRef
+  const watch: typeof import('vue').watch
+  const watchEffect: typeof import('vue').watchEffect
+  const watchPostEffect: typeof import('vue').watchPostEffect
+  const watchSyncEffect: typeof import('vue').watchSyncEffect
 }
 // for type re-export
 declare global {

+ 30 - 0
src/utils/casdoor/casdoor.ts

@@ -0,0 +1,30 @@
+import type { App } from 'vue'
+import casdoor, { useCasdoor } from 'casdoor-vue-sdk'
+
+const ServerUrl = '/casdoor'
+const ClientId = 'f7059ed03570512cc79c'
+const AppName = 'app-franklinwh-toolbox'
+const OrganizationName = 'organization_franklinwh_toolbox'
+const RedirectPath = '/fwh-toolbox/casdoor/callback'
+
+export function setupCasdoor(app: App) {
+  app.use(casdoor, {
+    serverUrl: ServerUrl,
+    clientId: ClientId,
+    appName: AppName,
+    organizationName: OrganizationName,
+    redirectPath: RedirectPath,
+  })
+}
+
+export function getSigninUrl() {
+  const casdoorSdk = useCasdoor()
+  return casdoorSdk.getSigninUrl()
+}
+
+export function jumpToSignin() {
+  const url = getSigninUrl()
+  console.log({ SigninUrl: url })
+  // 替换为你的登录页地址
+  window.location.href = url
+}

+ 0 - 1
src/views/sites-analysis/index.vue

@@ -18,7 +18,6 @@ import SiteTimeline from './component/siteTimeline.vue'
 import SiteRuntimeChart, { type RuntimeDataType } from './component/siteRuntimeChart.vue'
 import type { TimelineItem, RuntimeItem } from '@/service/sitesAnalysis/interface';
 import { getAnalysisData } from '@/service/sitesAnalysis';
-import { time } from 'console';
 
 defineOptions({
   name: 'SiteAnalysis',

+ 3 - 0
vite.config.ts

@@ -50,5 +50,8 @@ export default defineConfig(({ mode }) => {
       proxy: createProxy(env.VITE_PUBLIC_PATH, true),
       allowedHosts: true,
     },
+    build: {
+      sourcemap: true, // ✅ 生成 source map
+    },
   }
 })

Some files were not shown because too many files changed in this diff