package report import ( "context" "encoding/json" "strings" "time" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/rhythm" "github.com/yuxingu/digital-psychology/apps/api/internal/star" ) func cstLoc() *time.Location { return time.FixedZone("CST", 8*3600) } func dayKey(t time.Time) string { return t.In(cstLoc()).Format("2006-01-02") } func summaryDay(raw json.RawMessage) string { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return "" } s, _ := m["as_of"].(string) return s } func hasWuxingBars(raw json.RawMessage) bool { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return false } wx, _ := m["wuxing"].(map[string]any) if wx == nil { return false } bars, ok := wx["bars"].([]any) return ok && len(bars) > 0 } func rhythmNeedsContentBackfill(raw json.RawMessage) bool { if !hasWuxingBars(raw) { return true } var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return true } prev, _ := m["blind_spots_preview"].([]any) if len(prev) == 0 { return true } for _, item := range prev { s, _ := item.(string) if s == "完整习惯方案见深度版。" || strings.Contains(s, "见深度版") { return true } } return false } // refreshTemporalIfNeeded updates star/rhythm day-scoped tips in place when as_of is stale. func (s *Service) refreshTemporalIfNeeded(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) { if rep == nil || (rep.Type != "star" && rep.Type != "rhythm") { return rep, nil } now := time.Now().In(cstLoc()) today := dayKey(now) staleDay := summaryDay(rep.Summary) != today needBackfill := rep.Type == "rhythm" && rhythmNeedsContentBackfill(rep.Summary) if !staleDay && !needBackfill { return stampValidUntil(rep, now), nil } p, err := s.Profiles.GetForUser(ctx, userID, rep.ProfileID) if err != nil { return rep, nil } var sum, det json.RawMessage switch rep.Type { case "star": out, err := star.BuildWith(star.BuildOpts{ Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName, AsOf: now, }) if err != nil { return rep, nil } sum, _ = json.Marshal(out.Summary) det, _ = json.Marshal(out.Detail) case "rhythm": out := rhythm.BuildWith(p.BirthDate, p.DisplayName, now) sum, _ = json.Marshal(out.Summary) det, _ = json.Marshal(out.Detail) default: return rep, nil } if err := s.Reports.UpdateContent(ctx, userID, rep.ID, sum, det); err != nil { return rep, nil } rep.Summary = sum rep.Detail = det return rep, nil } func stampValidUntil(rep *model.GrowthReport, now time.Time) *model.GrowthReport { var m map[string]any if err := json.Unmarshal(rep.Summary, &m); err != nil { return rep } if _, ok := m["valid_until"].(string); ok && m["as_of"] == dayKey(now) { return rep } m["as_of"] = dayKey(now) next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) m["valid_until"] = next.Format(time.RFC3339) raw, err := json.Marshal(m) if err != nil { return rep } rep.Summary = raw return rep }