package service import ( "crypto/sha256" "encoding/hex" "sort" "strconv" "time" "xboard-go/internal/database" "xboard-go/internal/model" "github.com/google/uuid" ) const sessionTTL = 7 * 24 * time.Hour type SessionRecord struct { ID string `json:"id"` UserID int `json:"user_id"` TokenHash string `json:"token_hash"` Name string `json:"name"` UserAgent string `json:"user_agent"` IP string `json:"ip"` CreatedAt int64 `json:"created_at"` LastUsedAt int64 `json:"last_used_at"` ExpiresAt int64 `json:"expires_at"` } func TrackSession(userID int, token, ip, userAgent string) SessionRecord { now := time.Now().Unix() tokenHash := hashToken(token) list := activeSessions(userID, now) for i := range list { if list[i].TokenHash == tokenHash { list[i].IP = firstNonEmpty(ip, list[i].IP) list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent) list[i].LastUsedAt = now saveSessions(userID, list) syncUserSessionOnlineState(userID, list[i].IP, now) return list[i] } } record := SessionRecord{ ID: uuid.NewString(), UserID: userID, TokenHash: tokenHash, Name: "Web Session", UserAgent: userAgent, IP: ip, CreatedAt: now, LastUsedAt: now, ExpiresAt: now + int64(sessionTTL.Seconds()), } list = append(list, record) saveSessions(userID, list) syncUserSessionOnlineState(userID, record.IP, now) return record } func GetUserSessions(userID int, currentToken string) []SessionRecord { now := time.Now().Unix() list := activeSessions(userID, now) if currentToken != "" { currentHash := hashToken(currentToken) found := false for i := range list { if list[i].TokenHash == currentHash { found = true break } } if !found { list = append(list, SessionRecord{ ID: uuid.NewString(), UserID: userID, TokenHash: currentHash, Name: "Web Session", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + int64(sessionTTL.Seconds()), }) saveSessions(userID, list) } } return activeSessions(userID, now) } func RemoveUserSession(userID int, sessionID string) bool { now := time.Now().Unix() list := activeSessions(userID, now) next := make([]SessionRecord, 0, len(list)) removed := false for _, session := range list { if session.ID != sessionID { next = append(next, session) continue } removed = true ttl := time.Until(time.Unix(session.ExpiresAt, 0)) if ttl < time.Hour { ttl = time.Hour } _ = database.CacheSet(revokedTokenKey(session.TokenHash), true, ttl) } if removed { saveSessions(userID, next) } return removed } func IsSessionTokenRevoked(token string) bool { if token == "" { return false } _, ok := database.CacheGetJSON[bool](revokedTokenKey(hashToken(token))) return ok } func StoreQuickLoginToken(userID int, ttl time.Duration) string { verify := uuid.NewString() _ = database.CacheSet(quickLoginKey(verify), userID, ttl) return verify } func ResolveQuickLoginToken(verify string) (int, bool) { userID, ok := database.CacheGetJSON[int](quickLoginKey(verify)) if ok { _ = database.CacheDelete(quickLoginKey(verify)) } return userID, ok } func StoreEmailVerifyCode(email, code string, ttl time.Duration) { _ = database.CacheSet(emailVerifyKey(email), code, ttl) } func CheckEmailVerifyCode(email, code string) bool { savedCode, ok := database.CacheGetJSON[string](emailVerifyKey(email)) if !ok || savedCode == "" || savedCode != code { return false } _ = database.CacheDelete(emailVerifyKey(email)) return true } func activeSessions(userID int, now int64) []SessionRecord { sessions, ok := database.CacheGetJSON[[]SessionRecord](userSessionsKey(userID)) if !ok || len(sessions) == 0 { return []SessionRecord{} } filtered := make([]SessionRecord, 0, len(sessions)) for _, session := range sessions { if session.ExpiresAt > 0 && session.ExpiresAt <= now { continue } filtered = append(filtered, session) } sort.SliceStable(filtered, func(i, j int) bool { return filtered[i].LastUsedAt > filtered[j].LastUsedAt }) if len(filtered) != len(sessions) { saveSessions(userID, filtered) } return filtered } func saveSessions(userID int, sessions []SessionRecord) { sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].LastUsedAt > sessions[j].LastUsedAt }) _ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL) } func syncUserSessionOnlineState(userID int, ip string, now int64) { updates := map[string]any{ "last_online_at": time.Unix(now, 0), "updated_at": now, } if sessions := activeSessions(userID, now); len(sessions) > 0 { updates["online_count"] = len(sessions) } _ = database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error ip = firstNonEmpty(ip) if ip == "" { return } record := model.UserOnlineDevice{ UserID: userID, NodeID: 0, IP: ip, FirstSeenAt: now, LastSeenAt: now, ExpiresAt: now + int64(sessionTTL.Seconds()), CreatedAt: now, UpdatedAt: now, } var existing model.UserOnlineDevice if err := database.DB.Where("user_id = ? AND node_id = ? AND ip = ?", userID, 0, ip).First(&existing).Error; err == nil { _ = database.DB.Model(&model.UserOnlineDevice{}).Where("id = ?", existing.ID).Updates(map[string]any{ "last_seen_at": now, "expires_at": record.ExpiresAt, "updated_at": now, }).Error return } _ = database.DB.Create(&record).Error } func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func userSessionsKey(userID int) string { return "session:user:" + strconv.Itoa(userID) } func revokedTokenKey(tokenHash string) string { return "session:revoked:" + tokenHash } func quickLoginKey(verify string) string { return "session:quick-login:" + verify } func emailVerifyKey(email string) string { return "session:email-code:" + email } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" }