feat: P1 profile/portrait API and freeze local-dev environment

Add host-first environment contracts (Local vs CI vs Prod), deps-only
compose, and the Profile → Portrait → deep-access mock payment slice
with device identity and auto-migrate on API startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-02 16:24:57 +08:00
co-authored by Cursor
parent dd94e57277
commit 0f320e040b
49 changed files with 1907 additions and 191 deletions
@@ -0,0 +1,49 @@
package profile
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service manages personal archives.
type Service struct {
Repo *repository.ProfileRepo
}
// CreateInput is validated create payload.
type CreateInput struct {
Relation string
DisplayName string
BirthDate time.Time
RelationType *string
}
// Create stores a profile for the user.
func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) {
if in.Relation != "self" && in.Relation != "other" {
return nil, errors.New("relation must be self or other")
}
if in.BirthDate.IsZero() {
return nil, errors.New("birth_date required")
}
name := in.DisplayName
if name == "" {
if in.Relation == "self" {
name = "我"
} else {
name = "TA"
}
}
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType)
}
// List returns user's profiles.
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
return s.Repo.ListByUser(ctx, userID)
}
@@ -0,0 +1,87 @@
package report
import (
"context"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service creates and reads growth reports with entitlement trimming.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
}
// CreatePortrait builds and stores a portrait report.
func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
if err != nil {
return nil, errors.New("profile not found")
}
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)
if err != nil {
return nil, err
}
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)
if err != nil {
return nil, errors.New("report not found")
}
return s.applyEntitlement(ctx, userID, rep)
}
func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
if err != nil {
return nil, err
}
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
}
rep.HasDeep = deep || vip
if !rep.HasDeep {
rep.Detail = nil
}
return rep, nil
}
// CreateOrderInput for commerce.
type CreateOrderInput struct {
Kind string
Plan string
ReportID *uuid.UUID
}
// CreateOrder starts membership or deep_access order.
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
if in.Kind != "membership" && in.Kind != "deep_access" {
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
if in.Kind == "membership" {
amount = 2500
}
return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
}
// PayMock completes mock payment.
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
return s.Reports.PayMock(ctx, userID, orderID)
}