package service import ( "errors" "log" "strconv" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // DailyCheckinPoints 每日签到默认基础分(可被设置 points_checkin_base 覆盖) const DailyCheckinPoints = 5 // maxStreakScan 连续天数最多向前回溯的天数 const maxStreakScan = 400 // ErrAlreadyCheckedIn 今日已签到 var ErrAlreadyCheckedIn = errors.New("今日已签到") // CheckinStatus 签到状态(首页右栏与签到接口共用) type CheckinStatus struct { CheckedToday bool `json:"checked_today"` Streak int `json:"streak"` // 连续签到天数 TotalPoints int `json:"total_points"` // 累计积分(可用余额) TodayPoints int `json:"today_points"` // 今日签到可得基础积分(连续加成另行入账,流水可见) } // CheckinService 每日签到 type CheckinService struct { db *gorm.DB setting *SettingService } func NewCheckinService(db *gorm.DB) *CheckinService { return &CheckinService{db: db} } // WithSetting 注入站点设置(积分增长规则;未注入时用默认值) func (s *CheckinService) WithSetting(setting *SettingService) *CheckinService { s.setting = setting return s } // rules 读取积分规则;未注入或读取失败回落默认值(签到可用性优先于规则精度) func (s *CheckinService) rules() PointsRules { if s.setting == nil { return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays} } rules, err := s.setting.PointsRules() if err != nil { log.Printf("[checkin] 读取积分规则失败,使用默认值: %v", err) return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays} } return rules } // localDay 取本地时区零点日期 func localDay(t time.Time) time.Time { y, m, d := t.Date() return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) } // streakFrom 从 anchor(含)起按日倒推连续签到天数 func streakFrom(dates map[string]bool, anchor time.Time) int { streak := 0 for i := 0; i < maxStreakScan; i++ { day := anchor.AddDate(0, 0, -i) if !dates[day.Format("2006-01-02")] { break } streak++ } return streak } // streakDatesInTx 从事务连接查询 anchor 起回溯窗口内的签到日期集合 func streakDatesInTx(tx *gorm.DB, userID uint, anchor time.Time) (map[string]bool, error) { var dates []time.Time if err := tx.Model(&model.Checkin{}). Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)). Pluck("checkin_date", &dates).Error; err != nil { return nil, err } dateSet := make(map[string]bool, len(dates)) for _, d := range dates { dateSet[localDay(d).Format("2006-01-02")] = true } return dateSet, nil } // Status 查询用户签到状态 func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { today := localDay(time.Now()) base := s.rules().CheckinBase var todayRow model.Checkin err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today). First(&todayRow).Error checkedToday := true if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } checkedToday = false } // 连续天数:今日已签从今天起算,未签从昨天起算(今日尚未签不打断连续) anchor := today if !checkedToday { anchor = today.AddDate(0, 0, -1) } dateSet, err := streakDatesInTx(s.db, userID, anchor) if err != nil { return nil, err } var total int64 if err := s.db.Model(&model.User{}). Where("id = ?", userID). Select("points").Scan(&total).Error; err != nil { return nil, err } return &CheckinStatus{ CheckedToday: checkedToday, Streak: streakFrom(dateSet, anchor), TotalPoints: int(total), TodayPoints: base, }, nil } // CheckIn 执行今日签到(每日一条,唯一索引兜底并发)。 // 基础分与连续签到里程碑加成在同一事务内入账(加成规则可配置:每满 N 天额外 +X)。 func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) { today := localDay(time.Now()) rules := s.rules() var status *CheckinStatus err := s.db.Transaction(func(tx *gorm.DB) error { var count int64 if err := tx.Model(&model.Checkin{}). Where("user_id = ? AND checkin_date::date = ?::date", userID, today). Count(&count).Error; err != nil { return err } if count > 0 { return ErrAlreadyCheckedIn } row := model.Checkin{ UserID: userID, CheckinDate: today, Points: rules.CheckinBase, } if err := tx.Create(&row).Error; err != nil { return err } // 同步入账到用户积分余额 if _, err := CreditTx(tx, userID, rules.CheckinBase, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil { return err } // 连续签到里程碑加成(每满 N 天额外 +X;N=0 或加成=0 时关闭) if rules.StreakEveryDays > 0 && rules.StreakBonus > 0 { dateSet, err := streakDatesInTx(tx, userID, today) if err != nil { return err } if streak := streakFrom(dateSet, today); streak > 0 && streak%rules.StreakEveryDays == 0 { if _, err := CreditTx(tx, userID, rules.StreakBonus, model.PointReasonStreakBonus, "checkin", row.ID, "连续签到 "+strconv.Itoa(streak)+" 天奖励"); err != nil { return err } } } return nil }) if err != nil { return nil, err } status, err = s.Status(userID) if err != nil { return nil, err } return status, nil }