package storage import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "os" "path/filepath" "strings" "sync" "sproutgate-backend/internal/models" ) type AdminConfig struct { Token string `json:"token"` } type AuthConfig struct { JWTSecret string `json:"jwtSecret"` Issuer string `json:"issuer"` } type EmailConfig struct { FromName string `json:"fromName"` FromAddress string `json:"fromAddress"` Username string `json:"username"` Password string `json:"password"` SMTPHost string `json:"smtpHost"` SMTPPort int `json:"smtpPort"` Encryption string `json:"encryption"` } type CheckInConfig struct { RewardCoins int `json:"rewardCoins"` } type Store struct { dataDir string usersDir string pendingDir string resetDir string secondaryDir string adminConfigPath string authConfigPath string emailConfigPath string checkInPath string registrationPath string registrationConfig RegistrationConfig adminToken string jwtSecret []byte issuer string emailConfig EmailConfig checkInConfig CheckInConfig mu sync.Mutex } func NewStore(dataDir string) (*Store, error) { if dataDir == "" { dataDir = "./data" } absDir, err := filepath.Abs(dataDir) if err != nil { return nil, err } usersDir := filepath.Join(absDir, "users") pendingDir := filepath.Join(absDir, "pending") resetDir := filepath.Join(absDir, "reset") secondaryDir := filepath.Join(absDir, "secondary") configDir := filepath.Join(absDir, "config") if err := os.MkdirAll(usersDir, 0755); err != nil { return nil, err } if err := os.MkdirAll(pendingDir, 0755); err != nil { return nil, err } if err := os.MkdirAll(resetDir, 0755); err != nil { return nil, err } if err := os.MkdirAll(secondaryDir, 0755); err != nil { return nil, err } if err := os.MkdirAll(configDir, 0755); err != nil { return nil, err } store := &Store{ dataDir: absDir, usersDir: usersDir, pendingDir: pendingDir, resetDir: resetDir, secondaryDir: secondaryDir, adminConfigPath: filepath.Join(configDir, "admin.json"), authConfigPath: filepath.Join(configDir, "auth.json"), emailConfigPath: filepath.Join(configDir, "email.json"), checkInPath: filepath.Join(configDir, "checkin.json"), registrationPath: filepath.Join(configDir, "registration.json"), } if err := store.loadOrCreateAdminConfig(); err != nil { return nil, err } if err := store.loadOrCreateAuthConfig(); err != nil { return nil, err } if err := store.loadOrCreateEmailConfig(); err != nil { return nil, err } if err := store.loadOrCreateCheckInConfig(); err != nil { return nil, err } if err := store.loadOrCreateRegistrationConfig(); err != nil { return nil, err } return store, nil } func (s *Store) DataDir() string { return s.dataDir } func (s *Store) AdminToken() string { return s.adminToken } func (s *Store) JWTSecret() []byte { return s.jwtSecret } func (s *Store) JWTIssuer() string { return s.issuer } func (s *Store) EmailConfig() EmailConfig { return s.emailConfig } func (s *Store) CheckInConfig() CheckInConfig { s.mu.Lock() defer s.mu.Unlock() cfg := s.checkInConfig if cfg.RewardCoins <= 0 { cfg.RewardCoins = 1 } return cfg } func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error { s.mu.Lock() defer s.mu.Unlock() if cfg.RewardCoins <= 0 { cfg.RewardCoins = 1 } if err := writeJSONFile(s.checkInPath, cfg); err != nil { return err } s.checkInConfig = cfg return nil } func (s *Store) loadOrCreateAdminConfig() error { if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) { token, err := generateToken() if err != nil { return err } cfg := AdminConfig{Token: token} if err := writeJSONFile(s.adminConfigPath, cfg); err != nil { return err } s.adminToken = cfg.Token return nil } var cfg AdminConfig if err := readJSONFile(s.adminConfigPath, &cfg); err != nil { return err } if strings.TrimSpace(cfg.Token) == "" { token, err := generateToken() if err != nil { return err } cfg.Token = token if err := writeJSONFile(s.adminConfigPath, cfg); err != nil { return err } } s.adminToken = cfg.Token return nil } func (s *Store) loadOrCreateAuthConfig() error { if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) { secret, err := generateSecret() if err != nil { return err } cfg := AuthConfig{ JWTSecret: base64.StdEncoding.EncodeToString(secret), Issuer: "sproutgate", } if err := writeJSONFile(s.authConfigPath, cfg); err != nil { return err } s.jwtSecret = secret s.issuer = cfg.Issuer return nil } var cfg AuthConfig if err := readJSONFile(s.authConfigPath, &cfg); err != nil { return err } secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret) if err != nil || len(secretBytes) == 0 { secretBytes, err = generateSecret() if err != nil { return err } cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes) if strings.TrimSpace(cfg.Issuer) == "" { cfg.Issuer = "sproutgate" } if err := writeJSONFile(s.authConfigPath, cfg); err != nil { return err } } if strings.TrimSpace(cfg.Issuer) == "" { cfg.Issuer = "sproutgate" if err := writeJSONFile(s.authConfigPath, cfg); err != nil { return err } } s.jwtSecret = secretBytes s.issuer = cfg.Issuer return nil } func (s *Store) loadOrCreateEmailConfig() error { if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) { cfg := EmailConfig{ FromName: "萌芽账户认证中心", FromAddress: "notice@smyhub.com", Username: "", Password: "", SMTPHost: "smtp.qiye.aliyun.com", SMTPPort: 465, Encryption: "SSL", } if err := writeJSONFile(s.emailConfigPath, cfg); err != nil { return err } if cfg.Username == "" { cfg.Username = cfg.FromAddress } s.emailConfig = cfg return nil } var cfg EmailConfig if err := readJSONFile(s.emailConfigPath, &cfg); err != nil { return err } if strings.TrimSpace(cfg.FromName) == "" { cfg.FromName = "萌芽账户认证中心" } if strings.TrimSpace(cfg.FromAddress) == "" { cfg.FromAddress = "notice@smyhub.com" } if strings.TrimSpace(cfg.Username) == "" { cfg.Username = cfg.FromAddress } if strings.TrimSpace(cfg.SMTPHost) == "" { cfg.SMTPHost = "smtp.qiye.aliyun.com" } if cfg.SMTPPort == 0 { cfg.SMTPPort = 465 } if strings.TrimSpace(cfg.Encryption) == "" { cfg.Encryption = "SSL" } if err := writeJSONFile(s.emailConfigPath, cfg); err != nil { return err } s.emailConfig = cfg return nil } func (s *Store) loadOrCreateCheckInConfig() error { if _, err := os.Stat(s.checkInPath); errors.Is(err, os.ErrNotExist) { cfg := CheckInConfig{RewardCoins: 1} if err := writeJSONFile(s.checkInPath, cfg); err != nil { return err } s.checkInConfig = cfg return nil } var cfg CheckInConfig if err := readJSONFile(s.checkInPath, &cfg); err != nil { return err } if cfg.RewardCoins <= 0 { cfg.RewardCoins = 1 if err := writeJSONFile(s.checkInPath, cfg); err != nil { return err } } s.checkInConfig = cfg return nil } func generateSecret() ([]byte, error) { secret := make([]byte, 32) _, err := rand.Read(secret) return secret, err } func generateToken() (string, error) { secret, err := generateSecret() if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(secret), nil } func (s *Store) ListUsers() ([]models.UserRecord, error) { s.mu.Lock() defer s.mu.Unlock() entries, err := os.ReadDir(s.usersDir) if err != nil { return nil, err } users := make([]models.UserRecord, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } if !strings.HasSuffix(entry.Name(), ".json") { continue } var record models.UserRecord path := filepath.Join(s.usersDir, entry.Name()) if err := readJSONFile(path, &record); err != nil { return nil, err } users = append(users, record) } return users, nil } func (s *Store) GetUser(account string) (models.UserRecord, bool, error) { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return models.UserRecord{}, false, nil } var record models.UserRecord if err := readJSONFile(path, &record); err != nil { return models.UserRecord{}, false, err } return record, true, nil } func (s *Store) CreateUser(record models.UserRecord) error { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(record.Account) if _, err := os.Stat(path); err == nil { return errors.New("account already exists") } if record.CreatedAt == "" { record.CreatedAt = models.NowISO() } record.UpdatedAt = record.CreatedAt return writeJSONFile(path, record) } func (s *Store) SaveUser(record models.UserRecord) error { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(record.Account) record.UpdatedAt = models.NowISO() return writeJSONFile(path, record) } // RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。 func (s *Store) RecordAuthClient(account string, clientID string, displayName string) (models.UserRecord, error) { if clientID == "" { return models.UserRecord{}, errors.New("client id required") } s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) var record models.UserRecord if err := readJSONFile(path, &record); err != nil { if errors.Is(err, os.ErrNotExist) { return models.UserRecord{}, os.ErrNotExist } return models.UserRecord{}, err } now := models.NowISO() displayName = models.ClampAuthClientName(displayName) found := false for i := range record.AuthClients { if record.AuthClients[i].ClientID == clientID { record.AuthClients[i].LastSeenAt = now if displayName != "" { record.AuthClients[i].DisplayName = displayName } found = true break } } if !found { record.AuthClients = append(record.AuthClients, models.AuthClientEntry{ ClientID: clientID, DisplayName: displayName, FirstSeenAt: now, LastSeenAt: now, }) } record.UpdatedAt = now if err := writeJSONFile(path, &record); err != nil { return models.UserRecord{}, err } return record, nil } func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return models.UserRecord{}, false, os.ErrNotExist } var record models.UserRecord if err := readJSONFile(path, &record); err != nil { return models.UserRecord{}, false, err } if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) { return record, false, nil } if strings.TrimSpace(at) == "" { at = models.CurrentActivityTime() } record.LastVisitDate = today record.LastVisitAt = at record.VisitTimes = append(record.VisitTimes, at) if record.CreatedAt == "" { record.CreatedAt = models.NowISO() } record.UpdatedAt = models.NowISO() if err := writeJSONFile(path, record); err != nil { return models.UserRecord{}, false, err } return record, true, nil } const maxLastVisitIPLen = 45 const maxLastVisitDisplayLocationLen = 512 func clampVisitMeta(ip, displayLocation string) (string, string) { ip = strings.TrimSpace(ip) displayLocation = strings.TrimSpace(displayLocation) if len(ip) > maxLastVisitIPLen { ip = ip[:maxLastVisitIPLen] } if len(displayLocation) > maxLastVisitDisplayLocationLen { displayLocation = displayLocation[:maxLastVisitDisplayLocationLen] } return ip, displayLocation } // UpdateLastVisitMeta 更新用户最近一次访问的客户端 IP 与展示用地理位置(由前端调用地理接口后传入)。 func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) { ip, displayLocation = clampVisitMeta(ip, displayLocation) if ip == "" && displayLocation == "" { rec, found, err := s.GetUser(account) if err != nil { return models.UserRecord{}, err } if !found { return models.UserRecord{}, os.ErrNotExist } return rec, nil } s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return models.UserRecord{}, os.ErrNotExist } var record models.UserRecord if err := readJSONFile(path, &record); err != nil { return models.UserRecord{}, err } if ip != "" { record.LastVisitIP = ip } if displayLocation != "" { record.LastVisitDisplayLocation = displayLocation } record.UpdatedAt = models.NowISO() if err := writeJSONFile(path, record); err != nil { return models.UserRecord{}, err } return record, nil } func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return models.UserRecord{}, 0, false, os.ErrNotExist } var record models.UserRecord if err := readJSONFile(path, &record); err != nil { return models.UserRecord{}, 0, false, err } if record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) { return record, 0, true, nil } reward := s.checkInConfig.RewardCoins if reward <= 0 { reward = 1 } record.SproutCoins += reward record.LastCheckInDate = today if strings.TrimSpace(at) == "" { at = models.CurrentActivityTime() } record.LastCheckInAt = at record.CheckInTimes = append(record.CheckInTimes, at) if record.CreatedAt == "" { record.CreatedAt = models.NowISO() } record.UpdatedAt = models.NowISO() if err := writeJSONFile(path, record); err != nil { return models.UserRecord{}, 0, false, err } return record, reward, false, nil } func (s *Store) DeleteUser(account string) error { s.mu.Lock() defer s.mu.Unlock() path := s.userFilePath(account) if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return nil } return os.Remove(path) } func (s *Store) userFilePath(account string) string { return filepath.Join(s.usersDir, userFileName(account)) } func userFileName(account string) string { encoded := base64.RawURLEncoding.EncodeToString([]byte(account)) return encoded + ".json" } func readJSONFile(path string, target any) error { raw, err := os.ReadFile(path) if err != nil { return err } return json.Unmarshal(raw, target) } func writeJSONFile(path string, value any) error { raw, err := json.MarshalIndent(value, "", " ") if err != nil { return err } return os.WriteFile(path, raw, 0644) }