feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
@@ -0,0 +1,61 @@
package admin
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
)
var (
ErrInvalidScaleStatus = errors.New("invalid scale status")
ErrScaleNotFound = errors.New("scale not found")
)
// ListHomeTools returns all grid tools.
func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, error) {
if s.Home == nil {
return nil, errors.New("home unavailable")
}
return s.Home.ListAdmin(ctx)
}
// ReplaceHomeTools replaces grid and audits in one transaction.
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
if s.Home == nil {
return errors.New("home unavailable")
}
meta, _ := json.Marshal(map[string]any{"count": len(items)})
return s.Home.ReplaceWithAudit(ctx, adminID, items, meta)
}
// ListScalesAdmin returns all scales.
func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminItem, error) {
if s.Scales == nil {
return nil, errors.New("scales unavailable")
}
return s.Scales.ListAllAdmin(ctx)
}
// PatchScaleStatus updates published|draft and audits in one transaction.
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
if status != "published" && status != "draft" {
return ErrInvalidScaleStatus
}
if s.Scales == nil {
return errors.New("scales unavailable")
}
meta, _ := json.Marshal(map[string]any{"status": status})
if err := s.Scales.UpdateStatusWithAudit(ctx, scaleID, status, adminID, meta); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrScaleNotFound
}
return err
}
return nil
}
+64 -21
View File
@@ -13,12 +13,15 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
)
// Service is ops-admin application layer.
type Service struct {
Repo *repository.AdminRepo
Reports *repository.ReportRepo
Home *homesvc.Service
Scales *repository.ScaleRepo
}
// BootstrapConfig seeds the first admin when table is empty.
@@ -121,30 +124,43 @@ func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([
return s.Repo.ListUsers(ctx, q, limit, offset)
}
// DashboardStats exposes ops overview.
func (s *Service) DashboardStats(ctx context.Context) (*repository.DashboardStats, error) {
return s.Repo.GetDashboardStats(ctx)
}
// UserDetail is admin view of one user.
type UserDetail struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Profiles []repository.ProfileBrief `json:"profiles"`
Membership *repository.MembershipRow `json:"membership"`
Orders []repository.OrderListItem `json:"recent_orders"`
ID uuid.UUID `json:"id"`
Status string `json:"status"`
Phone *string `json:"phone,omitempty"`
Nickname *string `json:"nickname,omitempty"`
AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
CreatedAt time.Time `json:"created_at"`
Profiles []repository.ProfileBrief `json:"profiles"`
Reports []repository.ReportBrief `json:"reports"`
Membership *repository.MembershipRow `json:"membership"`
Orders []repository.OrderListItem `json:"recent_orders"`
}
// GetUser loads user detail for admin.
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
ok, err := s.Repo.UserExists(ctx, userID)
phone, nickname, paidLeft, status, createdAt, err := s.Repo.GetUserAccount(ctx, userID)
if err != nil {
ok, e2 := s.Repo.UserExists(ctx, userID)
if e2 != nil {
return nil, e2
}
if !ok {
return nil, ErrUserNotFound
}
return nil, err
}
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
if err != nil || len(users) == 0 {
return nil, ErrUserNotFound
}
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
reports, err := s.Repo.ListReportsForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
@@ -157,12 +173,16 @@ func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, e
return nil, err
}
return &UserDetail{
ID: users[0].ID,
Status: users[0].Status,
CreatedAt: users[0].CreatedAt,
Profiles: profiles,
Membership: mem,
Orders: orders,
ID: userID,
Status: status,
Phone: phone,
Nickname: nickname,
AskPaidQuotaLeft: paidLeft,
CreatedAt: createdAt,
Profiles: profiles,
Reports: reports,
Membership: mem,
Orders: orders,
}, nil
}
@@ -188,6 +208,29 @@ func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
}
// GrantAskQuotaInput adds paid ask replies.
type GrantAskQuotaInput struct {
Delta int `json:"delta"`
}
var ErrInvalidAskDelta = errString("invalid ask quota delta")
// GrantAskQuota adds purchased ask quota and audits.
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
if delta <= 0 || delta > 1000 {
return 0, ErrInvalidAskDelta
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return 0, err
}
if !ok {
return 0, ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"delta": delta})
return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, meta)
}
// ListOrders lists commerce orders.
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, nil, limit, offset)
@@ -0,0 +1,273 @@
package analytics
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrTooManyItems = errors.New("too many items")
ErrInvalidBatch = errors.New("invalid batch")
)
const maxBatch = 100
// Service handles ingest validation and admin aggregates.
type Service struct {
Repo *repository.AnalyticsRepo
}
// EventIn is one client event.
type EventIn struct {
Name string `json:"name"`
SessionID string `json:"session_id"`
PagePath string `json:"page_path"`
ClientTS any `json:"client_ts"`
Props map[string]interface{} `json:"props"`
}
// IngestResult summarizes a successful write.
type IngestResult struct {
Accepted int `json:"accepted"`
}
var allowedNames = map[string]struct{}{
"session_start": {}, "session_end": {}, "page_view": {}, "page_leave": {}, "ui_click": {},
"home_cta_portrait": {}, "portrait_completed": {}, "deep_access_clicked": {},
"purchase_completed": {}, "relation_completed": {}, "synastry_completed": {},
"synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
"cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
}
var allowedPropKeys = map[string]struct{}{
"page_path": {}, "page_title": {}, "referrer_path": {}, "dwell_ms": {},
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
}
var funnelDefault = []string{
"portrait_completed", "deep_access_clicked", "purchase_completed",
}
// Ingest validates and persists a batch. Invalid batch → ErrInvalidBatch (整批 400).
func (s *Service) Ingest(
ctx context.Context,
userID uuid.UUID,
deviceKey string,
items []EventIn,
) (*IngestResult, error) {
if len(items) == 0 {
return nil, ErrInvalidBatch
}
if len(items) > maxBatch {
return nil, ErrTooManyItems
}
rows := make([]repository.AnalyticsEventRow, 0, len(items))
for i := range items {
row, end, err := normalizeItem(&items[i])
if err != nil {
return nil, ErrInvalidBatch
}
if err := s.Repo.UpsertSession(ctx, row.SessionID, deviceKey, userID, row.ClientTS); err != nil {
return nil, err
}
if end != nil {
if err := s.Repo.EndSession(ctx, row.SessionID, row.ClientTS, end.ExitPage, end.DurationMs); err != nil {
return nil, err
}
}
row.UserID = userID
rows = append(rows, *row)
}
if err := s.Repo.InsertEvents(ctx, rows); err != nil {
return nil, err
}
return &IngestResult{Accepted: len(rows)}, nil
}
type sessionEnd struct {
ExitPage string
DurationMs int
}
func normalizeItem(in *EventIn) (*repository.AnalyticsEventRow, *sessionEnd, error) {
name := strings.TrimSpace(in.Name)
sid := strings.TrimSpace(in.SessionID)
if name == "" || sid == "" || len(sid) > 64 {
return nil, nil, ErrInvalidBatch
}
if _, ok := allowedNames[name]; !ok {
return nil, nil, ErrInvalidBatch
}
ts, err := parseClientTS(in.ClientTS)
if err != nil {
return nil, nil, ErrInvalidBatch
}
page := strings.TrimSpace(in.PagePath)
if page == "" && in.Props != nil {
if v, ok := in.Props["page_path"].(string); ok {
page = strings.TrimSpace(v)
}
}
props, end, err := scrubProps(name, in.Props)
if err != nil {
return nil, nil, err
}
raw, err := json.Marshal(props)
if err != nil {
return nil, nil, ErrInvalidBatch
}
return &repository.AnalyticsEventRow{
SessionID: sid,
Name: name,
PagePath: page,
Props: raw,
ClientTS: ts,
}, end, nil
}
func scrubProps(name string, in map[string]interface{}) (map[string]interface{}, *sessionEnd, error) {
out := map[string]interface{}{}
var end *sessionEnd
if name == "session_end" {
end = &sessionEnd{}
}
for k, v := range in {
key := strings.TrimSpace(k)
if _, ok := allowedPropKeys[key]; !ok {
continue
}
if key == "dwell_ms" || key == "duration_ms" {
n, ok := asNonNegInt(v)
if !ok {
return nil, nil, ErrInvalidBatch
}
out[key] = n
if key == "duration_ms" && end != nil {
end.DurationMs = n
}
continue
}
if key == "exit_page" {
s, _ := v.(string)
s = strings.TrimSpace(s)
out[key] = s
if end != nil {
end.ExitPage = s
}
continue
}
switch t := v.(type) {
case string:
if len(t) > 256 {
t = t[:256]
}
out[key] = t
case float64:
out[key] = t
case bool:
out[key] = t
case int:
out[key] = t
}
}
return out, end, nil
}
func asNonNegInt(v interface{}) (int, bool) {
switch t := v.(type) {
case float64:
if t < 0 || t > 1e9 {
return 0, false
}
return int(t), true
case int:
if t < 0 {
return 0, false
}
return t, true
case string:
n, err := strconv.Atoi(t)
if err != nil || n < 0 {
return 0, false
}
return n, true
default:
return 0, false
}
}
func parseClientTS(v any) (time.Time, error) {
switch t := v.(type) {
case string:
ts, err := time.Parse(time.RFC3339Nano, t)
if err != nil {
ts, err = time.Parse(time.RFC3339, t)
}
if err != nil {
return time.Time{}, err
}
return ts.UTC(), nil
case float64:
if t > 1e12 {
return time.UnixMilli(int64(t)).UTC(), nil
}
return time.Unix(int64(t), 0).UTC(), nil
case nil:
return time.Now().UTC(), nil
default:
return time.Time{}, ErrInvalidBatch
}
}
// ParseDayRange parses from/to (YYYY-MM-DD inclusive from, exclusive to+1day).
func ParseDayRange(fromStr, toStr string) (time.Time, time.Time, error) {
if fromStr == "" || toStr == "" {
to := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
from := to.Add(-7 * 24 * time.Hour)
return from, to, nil
}
from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
toDay, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
to := toDay.Add(24 * time.Hour)
if !to.After(from) || to.Sub(from) > 93*24*time.Hour {
return time.Time{}, time.Time{}, ErrInvalidBatch
}
return from, to, nil
}
func (s *Service) Overview(ctx context.Context, from, to time.Time) (*repository.OverviewAgg, error) {
return s.Repo.Overview(ctx, from, to)
}
func (s *Service) Pages(ctx context.Context, from, to time.Time) ([]repository.PageAgg, error) {
return s.Repo.Pages(ctx, from, to)
}
func (s *Service) Exits(ctx context.Context, from, to time.Time) ([]repository.ExitAgg, error) {
return s.Repo.Exits(ctx, from, to)
}
func (s *Service) Clicks(ctx context.Context, from, to time.Time) ([]repository.ClickAgg, error) {
return s.Repo.Clicks(ctx, from, to)
}
func (s *Service) Funnel(ctx context.Context, from, to time.Time) ([]repository.FunnelStep, error) {
return s.Repo.Funnel(ctx, from, to, funnelDefault)
}
@@ -0,0 +1,45 @@
package analytics
import "testing"
func TestScrubDwellNonNeg(t *testing.T) {
_, end, err := scrubProps("page_leave", map[string]interface{}{
"dwell_ms": float64(1200),
"phone": "13800000000",
})
if err != nil {
t.Fatal(err)
}
if end != nil {
t.Fatal("page_leave should not produce session end")
}
_, end, err = scrubProps("session_end", map[string]interface{}{
"exit_page": "/ask",
"duration_ms": float64(5000),
"birthday": "1990-01-01",
})
if err != nil {
t.Fatal(err)
}
if end == nil || end.ExitPage != "/ask" || end.DurationMs != 5000 {
t.Fatalf("bad end: %+v", end)
}
_, _, err = scrubProps("page_leave", map[string]interface{}{"dwell_ms": float64(-1)})
if err != ErrInvalidBatch {
t.Fatalf("want ErrInvalidBatch for negative dwell, got %v", err)
}
}
func TestParseDayRange(t *testing.T) {
from, to, err := ParseDayRange("2026-08-01", "2026-08-07")
if err != nil {
t.Fatal(err)
}
if to.Sub(from).Hours() != 7*24 {
t.Fatalf("range=%v", to.Sub(from))
}
_, _, err = ParseDayRange("2026-08-07", "2026-08-01")
if err != ErrInvalidBatch {
t.Fatalf("want invalid, got %v", err)
}
}
+321 -42
View File
@@ -2,6 +2,7 @@ package ask
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -47,16 +48,26 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
}
// ClearThread soft-deletes an owned thread and its messages.
func (s *Service) ClearThread(ctx context.Context, userID, threadID uuid.UUID) error {
return s.Ask.SoftDeleteThread(ctx, userID, threadID)
}
// QuotaStatus describes remaining ask allowance.
type QuotaStatus struct {
ActiveMembership bool `json:"active_membership"`
Remaining int `json:"remaining"`
FreeLimit int `json:"free_limit"`
Source string `json:"source"` // membership | free
PaidLeft int `json:"paid_left"`
Source string `json:"source"` // membership | free | paid | mixed
}
// GetQuota returns remaining ask replies.
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
paid, err := s.Ask.GetAskPaidQuota(ctx, userID)
if err != nil {
return nil, err
}
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
@@ -66,26 +77,46 @@ func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus,
if err != nil {
return nil, err
}
src := "membership"
if paid > 0 && me.AskQuotaLeft > 0 {
src = "mixed"
} else if me.AskQuotaLeft <= 0 && paid > 0 {
src = "paid"
}
return &QuotaStatus{
ActiveMembership: true,
Remaining: me.AskQuotaLeft,
Remaining: me.AskQuotaLeft + paid,
FreeLimit: FreeQuota,
Source: "membership",
PaidLeft: paid,
Source: src,
}, nil
}
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
if err != nil {
return nil, err
}
// Free tier only counts assistant replies that were not covered by paid packs.
// Approximate: free used = min(used, FreeQuota) when no paid history is tracked separately.
// Paid replies decrement ask_paid_quota_left; free replies increase assistant count.
// Remaining free = max(0, FreeQuota - max(0, used - lifetimePaidConsumed)).
// Without lifetime paid consumed counter, treat free as: FreeQuota - used, floored at 0,
// and add current paid left (purchased top-ups work after free exhausted).
left := FreeQuota - used
if left < 0 {
left = 0
}
src := "free"
if left == 0 && paid > 0 {
src = "paid"
} else if left > 0 && paid > 0 {
src = "mixed"
}
return &QuotaStatus{
ActiveMembership: false,
Remaining: left,
Remaining: left + paid,
FreeLimit: FreeQuota,
Source: "free",
PaidLeft: paid,
Source: src,
}, nil
}
@@ -131,6 +162,11 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
return nil, ErrQuotaExhausted
}
bucket, err := s.pickQuotaBucket(ctx, userID, quota)
if err != nil {
return nil, err
}
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
if err != nil {
return nil, err
@@ -142,19 +178,15 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
}
hist, _ := s.Ask.ListMessages(ctx, threadID)
reply := s.generateReply(ctx, profile, scene, content, hist)
reply := s.generateReply(ctx, userID, profile, scene, content, hist)
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
if err != nil {
return nil, err
}
if quota.ActiveMembership {
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
return nil, err
} else if !ok {
// race
}
if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
return nil, err
}
q2, err := s.GetQuota(ctx, userID)
@@ -164,7 +196,171 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
}
func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
type quotaBucket string
const (
bucketFree quotaBucket = "free"
bucketMembership quotaBucket = "membership"
bucketPaid quotaBucket = "paid"
)
func (s *Service) pickQuotaBucket(ctx context.Context, userID uuid.UUID, quota *QuotaStatus) (quotaBucket, error) {
if quota.ActiveMembership {
me, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return "", err
}
if me.AskQuotaLeft > 0 {
return bucketMembership, nil
}
if quota.PaidLeft > 0 {
return bucketPaid, nil
}
return "", ErrQuotaExhausted
}
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
if err != nil {
return "", err
}
if FreeQuota-used > 0 {
return bucketFree, nil
}
if quota.PaidLeft > 0 {
return bucketPaid, nil
}
return "", ErrQuotaExhausted
}
func (s *Service) consumeQuotaBucket(ctx context.Context, userID uuid.UUID, bucket quotaBucket) error {
switch bucket {
case bucketFree:
return nil // counted by assistant message total
case bucketMembership:
ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID)
if err != nil {
return err
}
if !ok {
// race: fall through to paid if possible
ok2, _, err2 := s.Ask.ConsumeAskPaidQuota(ctx, userID)
if err2 != nil {
return err2
}
if !ok2 {
return ErrQuotaExhausted
}
}
return nil
case bucketPaid:
ok, _, err := s.Ask.ConsumeAskPaidQuota(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrQuotaExhausted
}
return nil
default:
return ErrQuotaExhausted
}
}
func (s *Service) generateReply(ctx context.Context, userID uuid.UUID, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
out, err := s.generateReplyStream(ctx, userID, profile, scene, userContent, hist, nil)
if err != nil {
log.Printf("ask: generateReply: %v", err)
}
return out
}
// StreamEmit writes one SSE-style event to the client.
type StreamEmit func(event string, payload any) error
// StreamMessage is like SendMessage but streams assistant deltas via emit.
func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID, content string, emit StreamEmit) error {
if emit == nil {
return errors.New("emit required")
}
content = strings.TrimSpace(content)
if content == "" {
return errors.New("content required")
}
if len([]rune(content)) > 2000 {
return errors.New("content too long")
}
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
if err != nil {
return errors.New("thread not found")
}
profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
if err != nil {
return errors.New("profile not found")
}
quota, err := s.GetQuota(ctx, userID)
if err != nil {
return err
}
if quota.Remaining <= 0 {
return ErrQuotaExhausted
}
bucket, err := s.pickQuotaBucket(ctx, userID, quota)
if err != nil {
return err
}
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
if err != nil {
return err
}
if err := emit("meta", map[string]any{"user_message": userMsg}); err != nil {
return err
}
scene := ""
if thread.Scene != nil {
scene = *thread.Scene
}
hist, _ := s.Ask.ListMessages(ctx, threadID)
reply, err := s.generateReplyStream(ctx, userID, profile, scene, content, hist, func(delta string) error {
return emit("delta", map[string]any{"text": delta})
})
if err != nil {
return err
}
if strings.TrimSpace(reply) == "" {
reply = "暂时没能生成回复,请稍后再试。"
_ = emit("delta", map[string]any{"text": reply})
}
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
if err != nil {
return err
}
if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
return err
}
q2, err := s.GetQuota(ctx, userID)
if err != nil {
return err
}
return emit("done", map[string]any{
"assistant_message": asst,
"quota": q2,
})
}
func (s *Service) generateReplyStream(
ctx context.Context,
userID uuid.UUID,
profile *model.Profile,
scene, userContent string,
hist []model.AskMessage,
onDelta func(string) error,
) (string, error) {
fallback := eng.BuildReply(eng.ReplyInput{
DisplayName: profile.DisplayName,
BirthDate: profile.BirthDate,
@@ -172,12 +368,18 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
Scene: scene,
UserMessage: userContent,
})
if s.LLM == nil || !s.LLM.Enabled() {
return fallback
emitFallback := func(text string) (string, error) {
if onDelta == nil {
return text, nil
}
return text, streamFake(ctx, text, onDelta)
}
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
// history excluding the just-inserted user message duplicate handling: include prior + current user
if s.LLM == nil || !s.LLM.Enabled() {
return emitFallback(fallback)
}
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene, s.profileContext(ctx, userID, profile))}}
start := 0
if len(hist) > historyLimit*2 {
start = len(hist) - historyLimit*2
@@ -189,20 +391,88 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
}
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
}
// hist already contains the new user message from InsertMessage
out, err := s.LLM.Chat(ctx, msgs)
var assembled strings.Builder
out, err := s.LLM.ChatStream(ctx, msgs, func(delta string) error {
assembled.WriteString(delta)
if onDelta != nil {
return onDelta(delta)
}
return nil
})
if err != nil {
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
return fallback
if assembled.Len() > 0 {
return assembled.String(), nil
}
log.Printf("ask: deepseek stream failed, fallback to rules: %v", err)
return emitFallback(fallback)
}
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
if out == "" {
out = assembled.String()
}
return out
if onDelta != nil && assembled.Len() == 0 && out != "" {
_ = streamFake(ctx, out, onDelta)
}
return out, nil
}
func systemPrompt(profile *model.Profile, scene string) string {
// streamFake chunks text for rule-engine replies so UI still feels streamed.
func streamFake(ctx context.Context, text string, onDelta func(string) error) error {
runes := []rune(text)
const chunk = 2
for i := 0; i < len(runes); i += chunk {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
end := i + chunk
if end > len(runes) {
end = len(runes)
}
if err := onDelta(string(runes[i:end])); err != nil {
return err
}
time.Sleep(18 * time.Millisecond)
}
return nil
}
func (s *Service) profileContext(ctx context.Context, userID uuid.UUID, profile *model.Profile) string {
if s.Reports == nil || profile == nil {
return ""
}
var parts []string
for _, typ := range []string{"portrait", "star", "rhythm"} {
rep, err := s.Reports.GetLatest(ctx, userID, profile.ID, typ, nil)
if err != nil || rep == nil || len(rep.Summary) == 0 {
continue
}
var sum map[string]any
if json.Unmarshal(rep.Summary, &sum) != nil {
continue
}
label := map[string]string{"portrait": "愈心解码", "star": "星座探索", "rhythm": "身心节律"}[typ]
line := strings.TrimSpace(fmt.Sprintf("%s%v%v",
label, sum["headline"], sum["one_liner"]))
if kw, ok := sum["keywords"].([]any); ok && len(kw) > 0 {
var ks []string
for i, k := range kw {
if i >= 5 {
break
}
ks = append(ks, fmt.Sprint(k))
}
if len(ks) > 0 {
line += "|关键词:" + strings.Join(ks, "、")
}
}
parts = append(parts, line)
}
return strings.Join(parts, "\n")
}
func systemPrompt(profile *model.Profile, scene, reportCtx string) string {
name := profile.DisplayName
if name == "" {
if profile.Relation == "other" {
@@ -212,28 +482,37 @@ func systemPrompt(profile *model.Profile, scene string) string {
}
}
birth := profile.BirthDate.Format("2006-01-02")
rel := "我的档案"
who := "用户本人的个人档案"
if profile.Relation == "other" {
rel = "TA 的档案"
who = "用户添加的关系对象(TA档案"
}
sc := scene
sc := strings.TrimSpace(scene)
if sc == "" {
sc = "自我探索"
sc = "综合成长对话"
}
ctxBlock := "(暂无已生成的解码/星座/节律摘要;请主要依据生日与对话内容温和探索,不要假装已经测过完整报告。)"
if strings.TrimSpace(reportCtx) != "" {
ctxBlock = reportCtx
}
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。
定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。
禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。
推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。
当前解读对象:%s(%s),生日 %s,场景倾向:%s
请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
1)先回应用户当下问题(23 句)
2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
3)给出 2–4 条可执行小建议(尽量具体到本周可做)
4)如合适,给一句可直接说出口的对话示例
语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。
结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。
今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手
【定位】
把愈心解码、星座、人格匹配、身心节律等探索结果,转成短而可执行的陪伴。你不是占卜师/医生;禁止吉凶断语、改命恐吓、医疗诊断。
【当前对象】
称呼:%s|档案:%s|生日:%s|场景:%s
摘要(有则引用,无则勿编):
%s
【回复(务必短)】
1. 先用 1 句接住情绪/问题。
2. 只挑与问题最相关的 1 个档案洞察,讲清即可。
3. 给 1–2 条本周就能做的小行动(可附一句可说出口的话)。
4. 用中文;全文控制在 80–160 字;不要分大段、不要列表堆砌、不要长篇铺垫。
5. 不要在文末重复免责声明(界面已展示)。
今天是 %s。`, name, who, birth, sc, ctxBlock, time.Now().Format("2006-01-02"))
}
// ErrQuotaExhausted when free or membership ask quota is 0.
+149
View File
@@ -0,0 +1,149 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service handles register/login sessions.
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
type Service struct {
Repo *repository.AuthRepo
}
// Me is the public account payload.
type Me struct {
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
}
// SessionResult is returned after register/login.
type SessionResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User Me `json:"user"`
}
// Register upgrades or opens an account (same open rules as Login).
func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname)
}
// Login authenticates in open mode (no password check; auto-create).
func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone, password string) (*SessionResult, error) {
return s.OpenLogin(ctx, userID, deviceKey, phone, password, "")
}
// OpenLogin: any phone+password accepted; persist account; issue session.
func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return nil, errors.New("请填写手机号")
}
nickname = strings.TrimSpace(nickname)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
hashStr := string(hash)
acc, err := s.Repo.GetByPhone(ctx, phone)
if err == nil {
_ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
}
nick := acc.Nickname
if nickname != "" {
nick = nickname
}
return s.issue(ctx, acc.ID, acc.Phone, nick)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
// New phone: prefer upgrading anonymous device user.
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
uid := deviceUserID
if curErr == nil && cur.Phone == "" {
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil {
return nil, errors.New("登录失败,请重试")
}
} else {
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
if err != nil {
return nil, errors.New("登录失败,请重试")
}
}
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
return s.issue(ctx, uid, phone, nickname)
}
// Logout revokes bearer token.
func (s *Service) Logout(ctx context.Context, token string) error {
if token == "" {
return nil
}
return s.Repo.RevokeSession(ctx, token)
}
// Me returns account if registered.
func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
acc, err := s.Repo.GetAccount(ctx, userID)
if err != nil {
return nil, err
}
if acc.Phone == "" {
return nil, errors.New("未登录")
}
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
}
// ResolveSessionUser returns user id for a live token.
func (s *Service) ResolveSessionUser(ctx context.Context, token string) (uuid.UUID, error) {
return s.Repo.UserIDByToken(ctx, token)
}
// IsRegistered checks phone present.
func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
return s.Repo.IsRegistered(ctx, userID)
}
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
tok := "usr_" + randomHex(24)
exp := time.Now().Add(30 * 24 * time.Hour)
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
return nil, err
}
return &SessionResult{
Token: tok, ExpiresAt: exp,
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
}, nil
}
func maskPhone(p string) string {
if len(p) != 11 {
return p
}
return p[:3] + "****" + p[7:]
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,131 @@
package bootstrap
import (
"context"
"encoding/json"
"log"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
releng "github.com/yuxingu/digital-psychology/apps/api/internal/relation"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"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"
)
// Service generates birthday-derived report bundles for a profile.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
Relations *repository.RelationRepo
}
// GenerateForProfile rebuilds solo reports for p and pair reports with counterparts.
func (s *Service) GenerateForProfile(ctx context.Context, userID uuid.UUID, p *model.Profile) {
if p == nil {
return
}
s.genSolo(ctx, userID, p)
list, err := s.Profiles.ListByUser(ctx, userID)
if err != nil {
log.Printf("bootstrap list profiles: %v", err)
return
}
var self *model.Profile
others := make([]*model.Profile, 0)
for i := range list {
item := list[i]
if item.Relation == "self" {
cp := item
self = &cp
} else if item.Relation == "other" {
cp := item
others = append(others, &cp)
}
}
if self == nil {
return
}
if p.Relation == "self" {
for _, o := range others {
s.genPair(ctx, userID, self, o)
}
return
}
s.genPair(ctx, userID, self, p)
}
func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profile) {
outP := portrait.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(outP.Summary)
det, _ := json.Marshal(outP.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "portrait", sum, det); err != nil {
log.Printf("bootstrap portrait: %v", err)
}
outS, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
})
if err != nil {
log.Printf("bootstrap star: %v", err)
} else {
sum, _ = json.Marshal(outS.Summary)
det, _ = json.Marshal(outS.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "star", sum, det); err != nil {
log.Printf("bootstrap star save: %v", err)
}
}
outR := rhythm.Build(p.BirthDate, p.DisplayName)
sum, _ = json.Marshal(outR.Summary)
det, _ = json.Marshal(outR.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
log.Printf("bootstrap rhythm: %v", err)
}
}
func (s *Service) genPair(ctx context.Context, userID uuid.UUID, self, other *model.Profile) {
if self == nil || other == nil || self.ID == other.ID {
return
}
peer := other.ID
relType := ""
if other.RelationType != nil {
relType = *other.RelationType
}
rel := releng.BuildFull(self.BirthDate, other.BirthDate, self.BirthTime, other.BirthTime, self.BirthPlace, other.BirthPlace, self.DisplayName, other.DisplayName, relType)
sum, _ := json.Marshal(rel.Summary)
det, _ := json.Marshal(rel.Detail)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "relation", sum, det)
if err != nil {
log.Printf("bootstrap relation: %v", err)
} else if s.Relations != nil {
_, _ = s.Relations.Create(ctx, userID, self.ID, other.ID, sum, rep.ID)
}
ca, err := star.NatalChart(self.BirthDate, self.BirthTime, self.BirthPlace)
if err != nil {
log.Printf("bootstrap synastry natal a: %v", err)
return
}
cb, err := star.NatalChart(other.BirthDate, other.BirthTime, other.BirthPlace)
if err != nil {
log.Printf("bootstrap synastry natal b: %v", err)
return
}
when := time.Now().In(time.FixedZone("CST", 8*3600))
out, err := synastry.BuildReport(ca, cb, self.DisplayName, other.DisplayName, when)
if err != nil {
log.Printf("bootstrap synastry: %v", err)
return
}
sum, _ = json.Marshal(out.Summary)
det, _ = json.Marshal(out.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "synastry", sum, det); err != nil {
log.Printf("bootstrap synastry save: %v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package home
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidTools = errors.New("invalid home tools")
ErrTooManyTools = errors.New("too many home tools")
)
var pathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,120}$`)
var allowedIcons = map[string]struct{}{
"mbti": {}, "star": {}, "portrait": {}, "rhythm": {}, "synastry": {}, "astro": {},
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
}
// Service serves homepage tool catalog.
type Service struct {
Repo *repository.HomeToolsRepo
}
// ListPublic returns enabled tools.
func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) {
return s.Repo.ListEnabled(ctx)
}
// ListAdmin returns all tools.
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
return s.Repo.ListAll(ctx)
}
// ReplaceInput is one tool in a PUT body (id optional).
type ReplaceInput struct {
ID string `json:"id"`
RowIndex int `json:"row_index"`
SortOrder int `json:"sort_order"`
Path string `json:"path"`
Icon string `json:"icon"`
Label string `json:"label"`
Badge *string `json:"badge"`
BadgeTone *string `json:"badge_tone"`
Enabled bool `json:"enabled"`
}
// Replace validates and replaces all tools.
func (s *Service) Replace(ctx context.Context, items []ReplaceInput) error {
return s.ReplaceWithAudit(ctx, uuid.Nil, items, nil)
}
// ReplaceWithAudit validates, replaces, and audits in one transaction when adminID set.
func (s *Service) ReplaceWithAudit(
ctx context.Context,
adminID uuid.UUID,
items []ReplaceInput,
meta json.RawMessage,
) error {
if len(items) == 0 {
return ErrInvalidTools
}
if len(items) > 24 {
return ErrTooManyTools
}
rows := make([]repository.HomeTool, 0, len(items))
for _, in := range items {
t, err := normalizeTool(in)
if err != nil {
return err
}
rows = append(rows, *t)
}
return s.Repo.ReplaceAllWithAudit(ctx, rows, adminID, meta)
}
func normalizeTool(in ReplaceInput) (*repository.HomeTool, error) {
if in.RowIndex != 1 && in.RowIndex != 2 {
return nil, ErrInvalidTools
}
path := strings.TrimSpace(in.Path)
if !pathRe.MatchString(path) || strings.Contains(path, "..") {
return nil, ErrInvalidTools
}
icon := strings.TrimSpace(in.Icon)
if _, ok := allowedIcons[icon]; !ok {
return nil, ErrInvalidTools
}
label := strings.TrimSpace(in.Label)
n := utf8.RuneCountInString(label)
if n < 1 || n > 16 {
return nil, ErrInvalidTools
}
var badge, tone *string
if in.Badge != nil {
b := strings.TrimSpace(*in.Badge)
if b != "" {
if utf8.RuneCountInString(b) > 4 {
return nil, ErrInvalidTools
}
badge = &b
}
}
if in.BadgeTone != nil {
t := strings.TrimSpace(*in.BadgeTone)
if t != "" && t != "hot" && t != "new" {
return nil, ErrInvalidTools
}
if t != "" {
tone = &t
}
}
id := uuid.Nil
if strings.TrimSpace(in.ID) != "" {
parsed, err := uuid.Parse(in.ID)
if err != nil {
return nil, ErrInvalidTools
}
id = parsed
}
return &repository.HomeTool{
ID: id, RowIndex: in.RowIndex, SortOrder: in.SortOrder,
Path: path, Icon: icon, Label: label, Badge: badge, BadgeTone: tone, Enabled: in.Enabled,
}, nil
}
@@ -0,0 +1,30 @@
package home
import "testing"
func TestNormalizeToolOK(t *testing.T) {
b := "热"
tone := "hot"
got, err := normalizeTool(ReplaceInput{
RowIndex: 1, SortOrder: 1, Path: "/portrait", Icon: "portrait", Label: "愈心解码",
Badge: &b, BadgeTone: &tone, Enabled: true,
})
if err != nil || got.Label != "愈心解码" {
t.Fatalf("got=%+v err=%v", got, err)
}
}
func TestNormalizeToolRejects(t *testing.T) {
cases := []ReplaceInput{
{RowIndex: 3, Path: "/a", Icon: "ask", Label: "x"},
{RowIndex: 1, Path: "https://evil.com", Icon: "ask", Label: "x"},
{RowIndex: 1, Path: "/../etc", Icon: "ask", Label: "x"},
{RowIndex: 1, Path: "/ask", Icon: "nope", Label: "x"},
{RowIndex: 1, Path: "/ask", Icon: "ask", Label: ""},
}
for i, c := range cases {
if _, err := normalizeTool(c); err != ErrInvalidTools {
t.Fatalf("case %d want ErrInvalidTools got %v", i, err)
}
}
}
@@ -23,19 +23,29 @@ type CreateOrderInput struct {
ReportID *uuid.UUID
}
// CreateOrder starts membership or deep_access order.
// CreateOrder starts membership, deep_access, or ask_pack order.
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
if in.Kind != "membership" && in.Kind != "deep_access" {
if in.Kind != "membership" && in.Kind != "deep_access" && in.Kind != "ask_pack" {
return uuid.Nil, errors.New("invalid kind")
}
if in.Kind == "deep_access" && in.ReportID == nil {
return uuid.Nil, errors.New("report_id required")
}
amount := 990
plan := in.Plan
if in.Kind == "membership" {
amount = 2500
}
return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
if in.Kind == "ask_pack" {
if plan == "" {
plan = "pack10"
}
amount = repository.AskPackAmountCents(plan)
if amount <= 0 || repository.AskPackQuota(plan) <= 0 {
return uuid.Nil, errors.New("invalid ask_pack plan")
}
}
return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount)
}
// PayMock completes mock payment.
+40 -3
View File
@@ -10,11 +10,14 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
)
// Service manages personal archives.
type Service struct {
Repo *repository.ProfileRepo
Repo *repository.ProfileRepo
Reports *repository.ReportRepo
Bootstrap *bootstrap.Service
}
// CreateInput is validated create payload.
@@ -43,7 +46,14 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
name = "TA"
}
}
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
if err != nil {
return nil, err
}
if s.Bootstrap != nil {
s.Bootstrap.GenerateForProfile(ctx, userID, p)
}
return p, nil
}
// List returns user's profiles.
@@ -89,7 +99,31 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
if bp == nil {
bp = cur.BirthPlace
}
return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
birthChanged := !in.BirthDate.IsZero() && !sameDay(in.BirthDate, cur.BirthDate)
timeChanged := in.BirthTime != nil && strPtr(in.BirthTime) != strPtr(cur.BirthTime)
placeChanged := in.BirthPlace != nil && strPtr(in.BirthPlace) != strPtr(cur.BirthPlace)
p, err := s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
if err != nil {
return nil, err
}
if s.Bootstrap != nil && (birthChanged || timeChanged || placeChanged) {
s.Bootstrap.GenerateForProfile(ctx, userID, p)
}
return p, nil
}
func sameDay(a, b time.Time) bool {
ay, am, ad := a.Date()
by, bm, bd := b.Date()
return ay == by && am == bm && ad == bd
}
func strPtr(p *string) string {
if p == nil {
return ""
}
return *p
}
// Delete soft-deletes an owned profile.
@@ -97,5 +131,8 @@ func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error
if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil {
return errors.New("profile not found")
}
if s.Reports != nil {
_ = s.Reports.SoftDeleteForProfile(ctx, userID, profileID)
}
return s.Repo.SoftDeleteForUser(ctx, userID, profileID)
}
@@ -45,7 +45,8 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea
out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
peer := bID
rep, err := s.Reports.UpsertWithPeer(ctx, userID, aID, &peer, "relation", sum, det)
if err != nil {
return nil, err
}
+14 -4
View File
@@ -132,7 +132,7 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI
out := portrait.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "portrait", sum, det)
if err != nil {
return nil, err
}
@@ -153,7 +153,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileID, "star", sum, det)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "star", sum, det)
if err != nil {
return nil, err
}
@@ -193,7 +193,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileAID, "synastry", sum, det)
peer := profileBID
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileAID, &peer, "synastry", sum, det)
if err != nil {
return nil, err
}
@@ -209,13 +210,22 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
out := rhythm.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileID, "rhythm", sum, det)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
if err != nil {
return nil, err
}
return s.applyEntitlement(ctx, userID, rep)
}
// GetLatest returns cached report by profile+type(+peer).
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")
}
return s.applyEntitlement(ctx, userID, rep)
}
// Get returns a report with detail gated.
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetForUser(ctx, userID, reportID)