diff --git a/cmd/api/main.go b/cmd/api/main.go index 4896bd6..e70db76 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -8,13 +8,14 @@ import ( "github.com/gin-gonic/gin" "wx_service/config" + "wx_service/internal/achievement" adminmodule "wx_service/internal/admin" authhandler "wx_service/internal/common/auth/handler" authservice "wx_service/internal/common/auth/service" - uploadhandler "wx_service/internal/common/upload/handler" - uploadservice "wx_service/internal/common/upload/service" rediscache "wx_service/internal/common/redis/cache" redisservice "wx_service/internal/common/redis/service" + uploadhandler "wx_service/internal/common/upload/handler" + uploadservice "wx_service/internal/common/upload/service" oahandler "wx_service/internal/common/wechat_official/handler" oaservice "wx_service/internal/common/wechat_official/service" "wx_service/internal/database" @@ -30,7 +31,6 @@ import ( membershipservice "wx_service/internal/membership/service" "wx_service/internal/model" "wx_service/internal/observability" - "wx_service/internal/achievement" quitcheckinhandler "wx_service/internal/quitcheckin/handler" quitcheckinmodel "wx_service/internal/quitcheckin/model" quitcheckinservice "wx_service/internal/quitcheckin/service" @@ -88,6 +88,7 @@ func main() { &quitcheckinmodel.Profile{}, &quitcheckinmodel.DailyStatus{}, &quitcheckinmodel.RelapseEvent{}, + &quitcheckinmodel.HPChangeLog{}, &quitcheckinmodel.RewardGoal{}, &quitcheckinmodel.DreamPreset{}, &achievement.Theme{}, diff --git a/docs/sql/quitcheckin.sql b/docs/sql/quitcheckin.sql new file mode 100644 index 0000000..ec2485c --- /dev/null +++ b/docs/sql/quitcheckin.sql @@ -0,0 +1,32 @@ +-- QuitCheckin (V2) schema notes +-- This file documents the minimal DDL related to the HP persistence model (Phase 3 / issue #39). + +-- 1) Profile: add persistent HP field (nullable for migration compatibility) +ALTER TABLE `fa_quit_checkin_profile` + ADD COLUMN `hp_current` INT NULL COMMENT '肺部HP(0~100)' AFTER `reset_rule`; + +-- 2) HP change log: each HP delta is recorded for daily aggregation and future analytics +CREATE TABLE IF NOT EXISTS `fa_quit_checkin_hp_change_log` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `created_at` DATETIME(3) NOT NULL COMMENT '创建时间', + `updated_at` DATETIME(3) NOT NULL COMMENT '更新时间', + `deleted_at` DATETIME(3) NULL COMMENT '删除时间', + + `uid` INT NOT NULL COMMENT '用户ID', + `change_date` DATE NOT NULL COMMENT '所属自然日', + `change_at` DATETIME(3) NOT NULL COMMENT '变动时间', + + `delta` INT NOT NULL COMMENT '变动值(可正可负)', + `hp_before` INT NOT NULL COMMENT '变动前HP', + `hp_after` INT NOT NULL COMMENT '变动后HP', + `reason` VARCHAR(64) NOT NULL COMMENT '变动原因(checkin|smoke|relapse|migrate_init...)', + + `source_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源类型', + `source_id` BIGINT UNSIGNED NULL COMMENT '来源ID', + + PRIMARY KEY (`id`), + KEY `idx_quit_hp_uid_date` (`uid`, `change_date`), + KEY `idx_quit_hp_reason` (`reason`), + KEY `idx_quit_hp_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='V2-无烟打卡-HP变动日志'; + diff --git a/internal/quitcheckin/model/hp_change_log.go b/internal/quitcheckin/model/hp_change_log.go new file mode 100644 index 0000000..59b4f40 --- /dev/null +++ b/internal/quitcheckin/model/hp_change_log.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// HPChangeLog 记录每次 HP 变动明细,用于: +// - 首页展示“今日 +x / -x” +// - 后续趋势图、剧情、监督机制的数据基础 +type HPChangeLog struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + UID int `gorm:"index;comment:用户ID" json:"-"` + + // ChangeDate 用于按自然日聚合(例如:统计“今日变动”)。 + ChangeDate time.Time `gorm:"column:change_date;type:date;index;comment:所属自然日" json:"change_date"` + ChangeAt time.Time `gorm:"column:change_at;comment:变动时间" json:"change_at"` + + Delta int `gorm:"column:delta;comment:变动值(可正可负)" json:"delta"` + HPBefore int `gorm:"column:hp_before;comment:变动前HP" json:"hp_before"` + HPAfter int `gorm:"column:hp_after;comment:变动后HP" json:"hp_after"` + Reason string `gorm:"column:reason;size:64;index;comment:变动原因(checkin|smoke|relapse|migrate_init...)" json:"reason"` + + // Source 可选:记录来源(例如 smoke_log / relapse_event 等),便于排查与溯源。 + SourceType string `gorm:"column:source_type;size:32;comment:来源类型" json:"source_type,omitempty"` + SourceID *uint `gorm:"column:source_id;comment:来源ID" json:"source_id,omitempty"` +} + +func (HPChangeLog) TableName() string { + return "fa_quit_checkin_hp_change_log" +} + +func (HPChangeLog) TableComment() string { + return "V2-无烟打卡-HP变动日志" +} diff --git a/internal/quitcheckin/model/profile.go b/internal/quitcheckin/model/profile.go index a422f5f..d7f24f6 100644 --- a/internal/quitcheckin/model/profile.go +++ b/internal/quitcheckin/model/profile.go @@ -21,6 +21,9 @@ type Profile struct { Motivation string `gorm:"column:motivation;size:200;comment:戒烟初心" json:"motivation"` NotifyTime string `gorm:"column:notify_time;size:5;comment:提醒时间(HH:MM)" json:"notify_time"` ResetRule string `gorm:"column:reset_rule;size:64;comment:连续天数重置规则" json:"reset_rule"` + + // HpCurrent 表示“肺部 HP”(0~100)。允许为 NULL:用于兼容老数据,首次访问时再做迁移初始化。 + HpCurrent *int `gorm:"column:hp_current;comment:肺部HP(0~100)" json:"hp_current,omitempty"` } // TableName 返回用户资料表名。 diff --git a/internal/quitcheckin/service/hp_persistence_test.go b/internal/quitcheckin/service/hp_persistence_test.go new file mode 100644 index 0000000..9486faa --- /dev/null +++ b/internal/quitcheckin/service/hp_persistence_test.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "testing" + "time" + + quitmodel "wx_service/internal/quitcheckin/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupQuitHPTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &quitmodel.Profile{}, + &quitmodel.DailyStatus{}, + &quitmodel.RelapseEvent{}, + &quitmodel.HPChangeLog{}, + &quitmodel.RewardGoal{}, + &quitmodel.DreamPreset{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + return db +} + +func TestQuitCheckinHomeInitializesHP(t *testing.T) { + t.Parallel() + + db := setupQuitHPTestDB(t) + svc := NewService(db) + ctx := context.Background() + uid := 2001 + + startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local) + now := time.Date(2026, 4, 10, 9, 0, 0, 0, time.Local) + + if _, err := svc.UpsertProfile(ctx, uid, UpsertProfileRequest{ + QuitStartDate: &startDate, + PackPriceCent: intPtr(2500), + BaselineCigsPerDay: intPtr(12), + }, "测试用户", "", now); err != nil { + t.Fatalf("upsert profile: %v", err) + } + + home, err := svc.Home(ctx, uid, now) + if err != nil { + t.Fatalf("home: %v", err) + } + if home.Summary.HPCurrent <= 0 { + t.Fatalf("hp_current=%d, want > 0", home.Summary.HPCurrent) + } + if home.Summary.HPChangeToday != 0 { + t.Fatalf("hp_change_today=%d, want 0", home.Summary.HPChangeToday) + } +} + +func TestQuitCheckinCheckinIncreasesHPAndTracksTodayDelta(t *testing.T) { + t.Parallel() + + db := setupQuitHPTestDB(t) + svc := NewService(db) + ctx := context.Background() + uid := 2002 + + startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local) + day1 := time.Date(2026, 4, 10, 9, 0, 0, 0, time.Local) + + if _, err := svc.UpsertProfile(ctx, uid, UpsertProfileRequest{ + QuitStartDate: &startDate, + PackPriceCent: intPtr(2500), + BaselineCigsPerDay: intPtr(10), + }, "测试用户", "", day1); err != nil { + t.Fatalf("upsert profile: %v", err) + } + + before, err := svc.Home(ctx, uid, day1) + if err != nil { + t.Fatalf("home before: %v", err) + } + + _, err = svc.Checkin(ctx, uid, CheckinRequest{Date: day1, Note: "day1"}, day1) + if err != nil { + t.Fatalf("checkin: %v", err) + } + + after, err := svc.Home(ctx, uid, day1) + if err != nil { + t.Fatalf("home after: %v", err) + } + if after.Summary.HPCurrent <= before.Summary.HPCurrent { + t.Fatalf("hp_current before=%d after=%d, want after > before", before.Summary.HPCurrent, after.Summary.HPCurrent) + } + if after.Summary.HPChangeToday <= 0 { + t.Fatalf("hp_change_today=%d, want > 0", after.Summary.HPChangeToday) + } +} + +func TestQuitCheckinSmokeSlipDecreasesHPAndMarksRelapsed(t *testing.T) { + t.Parallel() + + db := setupQuitHPTestDB(t) + svc := NewService(db) + ctx := context.Background() + uid := 2003 + + startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local) + day1 := time.Date(2026, 4, 10, 9, 0, 0, 0, time.Local) + + if _, err := svc.UpsertProfile(ctx, uid, UpsertProfileRequest{ + QuitStartDate: &startDate, + PackPriceCent: intPtr(2500), + BaselineCigsPerDay: intPtr(8), + }, "测试用户", "", day1); err != nil { + t.Fatalf("upsert profile: %v", err) + } + + before, err := svc.Home(ctx, uid, day1) + if err != nil { + t.Fatalf("home before: %v", err) + } + + slipAt := time.Date(2026, 4, 10, 10, 15, 0, 0, time.Local) + if err := svc.RecordSmokeSlip(ctx, uid, slipAt, 1, "slip"); err != nil { + t.Fatalf("record slip: %v", err) + } + + after, err := svc.Home(ctx, uid, day1) + if err != nil { + t.Fatalf("home after: %v", err) + } + if after.DailyStatus.Status != quitmodel.DailyStatusRelapsed { + t.Fatalf("daily status=%s, want=%s", after.DailyStatus.Status, quitmodel.DailyStatusRelapsed) + } + if after.Summary.HPCurrent >= before.Summary.HPCurrent { + t.Fatalf("hp_current before=%d after=%d, want after < before", before.Summary.HPCurrent, after.Summary.HPCurrent) + } + if after.Summary.HPChangeToday >= 0 { + t.Fatalf("hp_change_today=%d, want < 0", after.Summary.HPChangeToday) + } + if after.Summary.CurrentStreakDays != 0 { + t.Fatalf("current_streak_days=%d, want 0", after.Summary.CurrentStreakDays) + } +} diff --git a/internal/quitcheckin/service/service.go b/internal/quitcheckin/service/service.go index 59a8802..4f84539 100644 --- a/internal/quitcheckin/service/service.go +++ b/internal/quitcheckin/service/service.go @@ -103,6 +103,8 @@ type SummaryResult struct { AvoidedCigs int `json:"avoided_cigs"` AvoidedCigsMode string `json:"avoided_cigs_mode"` HealthRecoveryPercent int `json:"health_recovery_percent"` + HPCurrent int `json:"hp_current"` + HPChangeToday int `json:"hp_change_today"` } // RewardGoalResult 表示梦想目标展示数据。 @@ -283,7 +285,7 @@ func (s *Service) GetProfile(ctx context.Context, uid int, nickname, avatarURL s return ProfileView{}, err } - summary, lastCheckin, lastRelapse, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, lastCheckin, lastRelapse, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return ProfileView{}, err } @@ -371,7 +373,7 @@ func (s *Service) Home(ctx context.Context, uid int, now time.Time) (HomeResult, return HomeResult{}, err } - summary, _, _, unlockedCount, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, unlockedCount, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return HomeResult{}, err } @@ -404,38 +406,69 @@ func (s *Service) Checkin(ctx context.Context, uid int, req CheckinRequest, now date := normalizeDate(req.Date) var status quitmodel.DailyStatus - err = s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + shouldApplyHP := false + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + checkinAt := now + if errors.Is(err, gorm.ErrRecordNotFound) { + status = quitmodel.DailyStatus{ + UID: uid, + Date: date, + Status: quitmodel.DailyStatusCheckedIn, + CheckInAt: &checkinAt, + Note: strings.TrimSpace(req.Note), + } + if e := tx.WithContext(ctx).Create(&status).Error; e != nil { + return e + } + shouldApplyHP = true + } else { + if status.Status == quitmodel.DailyStatusRelapsed { + return ErrAlreadyRelapsed + } + if status.Status != quitmodel.DailyStatusCheckedIn { + status.Status = quitmodel.DailyStatusCheckedIn + status.CheckInAt = &checkinAt + status.Note = strings.TrimSpace(req.Note) + if e := tx.WithContext(ctx).Save(&status).Error; e != nil { + return e + } + shouldApplyHP = true + } + } + + if !shouldApplyHP { + return nil + } + + hpBefore, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, now) + if err != nil { + return err + } + + // 打卡回血:前期更快,后期更慢 + delta := 2 + switch { + case hpBefore < 40: + delta = 4 + case hpBefore < 70: + delta = 3 + case hpBefore < 90: + delta = 2 + default: + delta = 1 + } + return s.applyHPDeltaTx(ctx, tx, profile, delta, "checkin", "daily_status", uintPtr(status.ID), now) + }) + if err != nil { return CheckinResult{}, err } - checkinAt := now - if errors.Is(err, gorm.ErrRecordNotFound) { - status = quitmodel.DailyStatus{ - UID: uid, - Date: date, - Status: quitmodel.DailyStatusCheckedIn, - CheckInAt: &checkinAt, - Note: strings.TrimSpace(req.Note), - } - if err := s.db.WithContext(ctx).Create(&status).Error; err != nil { - return CheckinResult{}, err - } - } else { - if status.Status == quitmodel.DailyStatusRelapsed { - return CheckinResult{}, ErrAlreadyRelapsed - } - if status.Status != quitmodel.DailyStatusCheckedIn { - status.Status = quitmodel.DailyStatusCheckedIn - status.CheckInAt = &checkinAt - status.Note = strings.TrimSpace(req.Note) - if err := s.db.WithContext(ctx).Save(&status).Error; err != nil { - return CheckinResult{}, err - } - } - } - - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return CheckinResult{}, err } @@ -472,6 +505,7 @@ func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + wasRelapsed := false if errors.Is(err, gorm.ErrRecordNotFound) { status = quitmodel.DailyStatus{ UID: uid, @@ -487,6 +521,7 @@ func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now return e } } else { + wasRelapsed = status.Status == quitmodel.DailyStatusRelapsed status.Status = quitmodel.DailyStatusRelapsed status.CheckInAt = nil status.RelapsedAt = &relapsedAt @@ -505,9 +540,27 @@ func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now RelapseNum: req.RelapseNum, Reason: strings.TrimSpace(req.Reason), Note: strings.TrimSpace(req.Note), - AffectStreak: true, + AffectStreak: !wasRelapsed, } - return tx.Create(&event).Error + if e := tx.Create(&event).Error; e != nil { + return e + } + + hpBefore, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, relapsedAt) + if err != nil { + return err + } + _ = hpBefore + + // 复吸:每支烟扣 M;当日首次复吸额外惩罚。 + perCig := 4 + delta := -perCig * maxInt(1, req.RelapseNum) + reason := "smoke" + if !wasRelapsed { + delta -= 10 + reason = "relapse" + } + return s.applyHPDeltaTx(ctx, tx, profile, delta, reason, "relapse_event", uintPtr(event.ID), relapsedAt) }) if err != nil { return RelapseResult{}, err @@ -518,7 +571,7 @@ func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now return RelapseResult{}, err } - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return RelapseResult{}, err } @@ -540,6 +593,88 @@ func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now }, nil } +// RecordSmokeSlip 用于 quit 模式下的“抽烟记录”同步到 quitcheckin: +// - 当天标记为 relapsed +// - 写入 relapse_event(同一天第二次 slip 不再 affect_streak) +// - 扣减 HP(每支烟扣 M;首次 slip 额外惩罚) +func (s *Service) RecordSmokeSlip(ctx context.Context, uid int, slipAt time.Time, slipNum int, note string) error { + if slipNum <= 0 { + return nil + } + + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + + date := normalizeDate(slipAt) + var status quitmodel.DailyStatus + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + wasRelapsed := false + if errors.Is(err, gorm.ErrRecordNotFound) { + status = quitmodel.DailyStatus{ + UID: uid, + Date: date, + Status: quitmodel.DailyStatusRelapsed, + RelapsedAt: &slipAt, + RelapseNum: slipNum, + Note: strings.TrimSpace(note), + CheckInAt: nil, + } + if e := tx.WithContext(ctx).Create(&status).Error; e != nil { + return e + } + } else { + wasRelapsed = status.Status == quitmodel.DailyStatusRelapsed + status.Status = quitmodel.DailyStatusRelapsed + status.CheckInAt = nil + status.RelapsedAt = &slipAt + // 多次 slip:累加当日 relapse_num,便于 avoided_cigs 统计更接近真实。 + status.RelapseNum += slipNum + if strings.TrimSpace(note) != "" && strings.TrimSpace(status.Note) == "" { + status.Note = strings.TrimSpace(note) + } + if e := tx.WithContext(ctx).Save(&status).Error; e != nil { + return e + } + } + + event := quitmodel.RelapseEvent{ + UID: uid, + Date: date, + RelapseAt: slipAt, + RelapseNum: slipNum, + Reason: "smoke_log", + Note: strings.TrimSpace(note), + AffectStreak: !wasRelapsed, + } + if e := tx.WithContext(ctx).Create(&event).Error; e != nil { + return e + } + + // 复吸:每支烟扣 M;当日首次复吸额外惩罚。 + perCig := 4 + delta := -perCig * maxInt(1, slipNum) + reason := "smoke" + if !wasRelapsed { + delta -= 10 + reason = "relapse" + } + if _, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, slipAt); err != nil { + return err + } + return s.applyHPDeltaTx(ctx, tx, profile, delta, reason, "smoke_log", nil, slipAt) + }) +} + // StatsOverview 返回统计概览。 func (s *Service) StatsOverview(ctx context.Context, uid int, rangeName string, now time.Time) (StatsOverviewResult, error) { profile, err := s.loadProfile(ctx, uid) @@ -596,7 +731,7 @@ func (s *Service) StatsOverview(ctx context.Context, uid int, rangeName string, trend = append(trend, TrendItemResult{Date: key, Status: item.Status, RelapseNum: item.RelapseNum}) } - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return StatsOverviewResult{}, err } @@ -626,7 +761,7 @@ func (s *Service) ListBadges(ctx context.Context, uid int, now time.Time) (Badge return BadgeListResult{}, err } - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return BadgeListResult{}, err } @@ -814,7 +949,7 @@ func (s *Service) PosterData(ctx context.Context, uid int, nickname string, temp return PosterDataResult{}, err } - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return PosterDataResult{}, err } @@ -867,6 +1002,151 @@ func (s *Service) loadProfile(ctx context.Context, uid int) (*quitmodel.Profile, return &profile, nil } +func (s *Service) ensureHPInitialized(ctx context.Context, profile *quitmodel.Profile, currentStreak int, now time.Time) (int, error) { + if profile.HpCurrent != nil { + return clampHP(*profile.HpCurrent), nil + } + + // 迁移/初始化:只依赖 quitcheckin profile 的 baseline + 当前 streak,避免跨模块耦合。 + base := 50 + if profile.BaselineCigsPerDay > 0 { + base = 60 - profile.BaselineCigsPerDay + } + base = clampInt(base, 20, 60) + + initHP := clampHP(base + currentStreak*3) + profile.HpCurrent = &initHP + + changeDate := normalizeDate(now) + row := quitmodel.HPChangeLog{ + UID: profile.UID, + ChangeDate: changeDate, + ChangeAt: now, + Delta: 0, + HPBefore: initHP, + HPAfter: initHP, + Reason: "migrate_init", + SourceType: "profile", + } + + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 条件更新,避免并发覆盖(nil -> initHP 只做一次)。 + if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}). + Where("id = ? AND uid = ? AND hp_current IS NULL", profile.ID, profile.UID). + Updates(map[string]interface{}{"hp_current": initHP}).Error; e != nil { + return e + } + return tx.WithContext(ctx).Create(&row).Error + }); err != nil { + return 0, err + } + + return initHP, nil +} + +func (s *Service) sumHPChangeByDate(ctx context.Context, uid int, date time.Time) (int, error) { + date = normalizeDate(date) + var sum int64 + if err := s.db.WithContext(ctx). + Model(&quitmodel.HPChangeLog{}). + Where("uid = ? AND change_date = ?", uid, date). + Select("COALESCE(SUM(delta), 0)"). + Scan(&sum).Error; err != nil { + return 0, err + } + return int(sum), nil +} + +func (s *Service) ensureHPInitializedBasicTx(ctx context.Context, tx *gorm.DB, profile *quitmodel.Profile, now time.Time) (int, error) { + if profile.HpCurrent != nil { + return clampHP(*profile.HpCurrent), nil + } + + // 基础初始化:不引入 streak(事件触发时先给个合理起点;更准确的迁移会在 computeSummary 中做)。 + base := 50 + if profile.BaselineCigsPerDay > 0 { + base = 60 - profile.BaselineCigsPerDay + } + base = clampInt(base, 20, 60) + + initHP := clampHP(base) + profile.HpCurrent = &initHP + + if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}). + Where("id = ? AND uid = ? AND hp_current IS NULL", profile.ID, profile.UID). + Updates(map[string]interface{}{"hp_current": initHP}).Error; e != nil { + return 0, e + } + row := quitmodel.HPChangeLog{ + UID: profile.UID, + ChangeDate: normalizeDate(now), + ChangeAt: now, + Delta: 0, + HPBefore: initHP, + HPAfter: initHP, + Reason: "migrate_init", + SourceType: "profile", + } + if e := tx.WithContext(ctx).Create(&row).Error; e != nil { + return 0, e + } + + return initHP, nil +} + +func (s *Service) applyHPDeltaTx(ctx context.Context, tx *gorm.DB, profile *quitmodel.Profile, delta int, reason string, sourceType string, sourceID *uint, changeAt time.Time) error { + before := 0 + if profile.HpCurrent != nil { + before = clampHP(*profile.HpCurrent) + } + after := clampHP(before + delta) + + if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}). + Where("id = ? AND uid = ?", profile.ID, profile.UID). + Updates(map[string]interface{}{"hp_current": after}).Error; e != nil { + return e + } + profile.HpCurrent = &after + + row := quitmodel.HPChangeLog{ + UID: profile.UID, + ChangeDate: normalizeDate(changeAt), + ChangeAt: changeAt, + Delta: after - before, + HPBefore: before, + HPAfter: after, + Reason: strings.TrimSpace(reason), + SourceType: strings.TrimSpace(sourceType), + SourceID: sourceID, + } + return tx.WithContext(ctx).Create(&row).Error +} + +func clampHP(v int) int { + return clampInt(v, 0, 100) +} + +func clampInt(v, minV, maxV int) int { + if v < minV { + return minV + } + if v > maxV { + return maxV + } + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func uintPtr(v uint) *uint { + return &v +} + func (s *Service) getOrBuildDailyStatus(ctx context.Context, uid int, date, now time.Time) (quitmodel.DailyStatus, error) { var status quitmodel.DailyStatus err := s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error @@ -902,14 +1182,14 @@ func (s *Service) currentSavedMoney(ctx context.Context, uid int, now time.Time) } return 0, err } - summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + summary, _, _, _, err := s.computeSummary(ctx, uid, profile, now) if err != nil { return 0, err } return summary.SavedMoneyCent, nil } -func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel.Profile, now time.Time) (SummaryResult, *string, *string, int, error) { +func (s *Service) computeSummary(ctx context.Context, uid int, profile *quitmodel.Profile, now time.Time) (SummaryResult, *string, *string, int, error) { today := normalizeDate(now) var statuses []quitmodel.DailyStatus @@ -1007,6 +1287,15 @@ func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel } } + hpCurrent, err := s.ensureHPInitialized(ctx, profile, currentStreak, now) + if err != nil { + return SummaryResult{}, nil, nil, 0, err + } + hpChangeToday, err := s.sumHPChangeByDate(ctx, uid, today) + if err != nil { + return SummaryResult{}, nil, nil, 0, err + } + return SummaryResult{ CurrentStreakDays: currentStreak, MaxStreakDays: maxStreak, @@ -1016,6 +1305,8 @@ func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel AvoidedCigs: avoidedCigs, AvoidedCigsMode: "exact", HealthRecoveryPercent: healthPercent, + HPCurrent: hpCurrent, + HPChangeToday: hpChangeToday, }, lastCheckin, lastRelapse, unlockedCount, nil } diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 1dc7dab..2015545 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -3,6 +3,7 @@ package handler import ( "errors" "io" + "log" "net/http" "strconv" "strings" @@ -134,6 +135,22 @@ func (h *SmokeHandler) Create(c *gin.Context) { return } + // quit 模式下:把“抽烟记录”视作一次 slip/复吸,同步到 quitcheckin(扣 HP + 标记当日 relapsed)。 + // 这一步失败不影响主流程(记录仍应成功写入 smoke_log)。 + if record != nil && record.Num > 0 { + if profile, e := h.smokeProfileService.Get(c.Request.Context(), int(user.ID)); e == nil && profile != nil { + if strings.TrimSpace(strings.ToLower(profile.Mode)) == "quit" { + slipAt := time.Now().In(time.Local) + if record.SmokeAt != nil { + slipAt = record.SmokeAt.In(time.Local) + } + if e := h.quitCheckinService.RecordSmokeSlip(c.Request.Context(), int(user.ID), slipAt, record.Num, record.Remark); e != nil { + log.Printf("[smoke_create] sync quitcheckin slip degraded uid=%d err=%v", user.ID, e) + } + } + } + } + c.JSON(http.StatusOK, model.Success(record)) }