feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
// Service creates and reads growth reports with entitlement trimming.
|
||||
@@ -98,6 +99,10 @@ func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token,
|
||||
if name == "" {
|
||||
name = "TA"
|
||||
}
|
||||
name, err = textsafe.Check(textsafe.DisplayName, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("host profile missing")
|
||||
@@ -147,6 +152,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
|
||||
}
|
||||
out, err := star.BuildWith(star.BuildOpts{
|
||||
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
|
||||
AsOf: time.Now().In(time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -212,7 +218,7 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
out := rhythm.Build(p.BirthDate, p.DisplayName)
|
||||
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, time.Now().In(time.FixedZone("CST", 8*3600)))
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
|
||||
@@ -222,21 +228,23 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// GetLatest returns cached report by profile+type(+peer).
|
||||
// GetLatest returns cached report by profile+type(+peer); refreshes star/rhythm day tips.
|
||||
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// Get returns a report with detail gated.
|
||||
// Get returns a report with detail gated; refreshes star/rhythm day tips.
|
||||
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
|
||||
)
|
||||
|
||||
func TestDayKeyCST(t *testing.T) {
|
||||
// 2026-08-12 23:30 UTC = 2026-08-13 07:30 CST
|
||||
utc := time.Date(2026, 8, 12, 23, 30, 0, 0, time.UTC)
|
||||
if got := dayKey(utc); got != "2026-08-13" {
|
||||
t.Fatalf("dayKey=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarOutlookChangesByDay(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 15, 0, 0, 0, 0, time.UTC)
|
||||
d1 := time.Date(2026, 8, 12, 10, 0, 0, 0, cstLoc())
|
||||
d2 := time.Date(2026, 8, 13, 10, 0, 0, 0, cstLoc())
|
||||
a, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Summary["as_of"] != "2026-08-12" || b.Summary["as_of"] != "2026-08-13" {
|
||||
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
|
||||
}
|
||||
oa, _ := a.Summary["outlook"].(map[string]any)
|
||||
ob, _ := b.Summary["outlook"].(map[string]any)
|
||||
da, _ := oa["daily"].(map[string]any)
|
||||
db, _ := ob["daily"].(map[string]any)
|
||||
if da["score"] == db["score"] && da["tip"] == db["tip"] {
|
||||
t.Fatalf("expected daily outlook to differ across days")
|
||||
}
|
||||
if a.Summary["valid_until"] == nil || b.Summary["valid_until"] == nil {
|
||||
t.Fatal("missing valid_until")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRhythmTipsChangeByDay(t *testing.T) {
|
||||
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
d1 := time.Date(2026, 8, 10, 12, 0, 0, 0, cstLoc()) // Mon
|
||||
d2 := time.Date(2026, 8, 11, 12, 0, 0, 0, cstLoc()) // Tue
|
||||
a := rhythm.BuildWith(birth, "测", d1)
|
||||
b := rhythm.BuildWith(birth, "测", d2)
|
||||
if a.Summary["as_of"] != "2026-08-10" || b.Summary["as_of"] != "2026-08-11" {
|
||||
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
|
||||
}
|
||||
if a.Summary["week_focus"] == b.Summary["week_focus"] && a.Summary["today_tip"] == b.Summary["today_tip"] {
|
||||
t.Fatalf("expected tips to differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryDay(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"as_of": "2026-08-12"})
|
||||
if summaryDay(raw) != "2026-08-12" {
|
||||
t.Fatal(summaryDay(raw))
|
||||
}
|
||||
if summaryDay(json.RawMessage(`{}`)) != "" {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user