feat(ECR-011): 昵称、首页贴士与档案 Self 唯一/合盘交叉校验
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-12 22:05:00 +08:00
co-authored by Cursor
parent 5ceb3ce749
commit 13860bf1ef
67 changed files with 1936 additions and 275 deletions
+36 -8
View File
@@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
@@ -46,12 +47,12 @@ func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone,
}
// 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) {
func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickIn string) (*SessionResult, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return nil, errors.New("请填写手机号")
}
nickname = strings.TrimSpace(nickname)
nickIn = nickgen.Normalize(nickIn)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -65,8 +66,12 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
}
nick := acc.Nickname
if nickname != "" {
nick = nickname
if nickIn != "" {
nick = nickIn
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
} else if nick == "" {
nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
}
return s.issue(ctx, acc.ID, acc.Phone, nick)
}
@@ -74,15 +79,19 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
return nil, err
}
// New phone: prefer upgrading anonymous device user.
nick := nickIn
if nick == "" {
nick = nickgen.Random()
}
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 {
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nick); err != nil {
return nil, errors.New("登录失败,请重试")
}
} else {
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nick)
if err != nil {
return nil, errors.New("登录失败,请重试")
}
@@ -90,7 +99,26 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
return s.issue(ctx, uid, phone, nickname)
return s.issue(ctx, uid, phone, nick)
}
// UpdateNickname changes account display nickname (116 chars).
func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) {
nick = nickgen.Normalize(nick)
if nick == "" {
return nil, errors.New("请填写昵称")
}
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
return nil, err
}
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
}
// Logout revokes the current bearer session and unbinds the device from the account
@@ -0,0 +1,217 @@
package home
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/yijing"
)
// ColorChip is one palette swatch.
type ColorChip struct {
Name string `json:"name"`
Hex string `json:"hex"`
}
// DailyTips is homepage self-card lifestyle tips.
type DailyTips struct {
ClothingIndex int `json:"clothing_index"`
Clothing string `json:"clothing"`
ColorNote string `json:"color_note"`
Palette []ColorChip `json:"palette"`
Wellness string `json:"wellness"`
Source string `json:"source"` // llm | fallback
GuaName string `json:"gua_name,omitempty"`
DayPart string `json:"day_part,omitempty"`
NeedBirth bool `json:"need_birth"`
}
type tipsCacheEntry struct {
tips DailyTips
exp time.Time
}
var tipsCache sync.Map // key string → tipsCacheEntry
// DailyTips returns personalized tips; falls back when LLM / profile unavailable.
func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) {
now := time.Now()
self, ok := s.findSelf(ctx, userID)
if !ok {
fb := fallbackTips(now, "", nil)
fb.NeedBirth = true
return fb, nil
}
birth := self.BirthDate.Format("2006-01-02")
yc := yijing.Seed(birth, self.BirthTime, now)
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
if v, hit := tipsCache.Load(cacheKey); hit {
e := v.(tipsCacheEntry)
if time.Now().Before(e.exp) {
out := e.tips
return &out, nil
}
}
if s.LLM == nil || !s.LLM.Enabled() {
fb := fallbackTips(now, birth, self.BirthTime)
fb.GuaName = yc.GuaName
fb.DayPart = yc.DayPart
return fb, nil
}
tips, err := s.generateLLMTips(ctx, yc)
if err != nil {
log.Printf("home daily-tips llm: %v", err)
fb := fallbackTips(now, birth, self.BirthTime)
fb.GuaName = yc.GuaName
fb.DayPart = yc.DayPart
return fb, nil
}
tips.Source = "llm"
tips.GuaName = yc.GuaName
tips.DayPart = yc.DayPart
tips.NeedBirth = false
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)})
return tips, nil
}
func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) {
if s.Profiles == nil || userID == uuid.Nil {
return nil, false
}
items, err := s.Profiles.ListByUser(ctx, userID)
if err != nil {
return nil, false
}
for i := range items {
if items[i].Relation == "self" {
return &items[i], true
}
}
return nil, false
}
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) {
sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时刻的易经卦象种子,给出温和的生活建议。
硬性要求:
- 禁止占卜/算命/改命恐吓/吉凶祸福话术
- 禁止医疗疗效承诺
- 用探索向、身心节律语气;可参考卦象意象,不要说「必有」类断言
- 只输出 JSON,不要 markdown
{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"}
- clothing_index 为 5595 整数;palette 恰好 2 项;文案各不超过 40 字`
user := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。"
raw, err := s.LLM.Chat(ctx, []deepseek.Message{
{Role: "system", Content: sys},
{Role: "user", Content: user},
})
if err != nil {
return nil, err
}
return parseTipsJSON(raw)
}
func parseTipsJSON(raw string) (*DailyTips, error) {
raw = strings.TrimSpace(raw)
if i := strings.Index(raw, "{"); i >= 0 {
raw = raw[i:]
}
if j := strings.LastIndex(raw, "}"); j >= 0 {
raw = raw[:j+1]
}
var t DailyTips
if err := json.Unmarshal([]byte(raw), &t); err != nil {
return nil, err
}
if t.ClothingIndex < 40 || t.ClothingIndex > 100 {
t.ClothingIndex = 72
}
t.Clothing = clampRunes(t.Clothing, 48)
t.ColorNote = clampRunes(t.ColorNote, 40)
t.Wellness = clampRunes(t.Wellness, 48)
if len(t.Palette) != 2 {
return nil, fmt.Errorf("palette len")
}
for i := range t.Palette {
t.Palette[i].Name = clampRunes(t.Palette[i].Name, 8)
if !looksHex(t.Palette[i].Hex) {
t.Palette[i].Hex = "#E8DCC8"
}
}
return &t, nil
}
func clampRunes(s string, max int) string {
s = strings.TrimSpace(s)
if utf8.RuneCountInString(s) <= max {
return s
}
r := []rune(s)
return string(r[:max])
}
func looksHex(h string) bool {
if len(h) != 7 || h[0] != '#' {
return false
}
for i := 1; i < 7; i++ {
c := h[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
yc := yijing.Seed(birth, birthTime, now)
if birth == "" {
yc = yijing.Seed(now.Format("2006-01-02"), nil, now)
}
palettes := [][]ColorChip{
{{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}},
{{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}},
{{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}},
{{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}},
{{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}},
}
clothing := []string{
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
"选柔软面料贴身,活动一整天也不易紧绷。",
"上下同色系更省心,再加一件小配饰点睛即可。",
"宽松剪裁更自在,适合慢节奏出门与散步。",
"内里干净简约,外搭一件有质感的外套就够。",
}
notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"}
well := []string{
"午后泡一杯温茶,给身心一点缓冲。",
"今晚早点放下屏幕,让眼睛歇一会儿。",
"走路时把肩膀放松,呼吸会顺很多。",
"饭后慢走十分钟,比猛坐着更舒服。",
"喝水提醒自己小口多次,别等口渴再灌。",
}
i := (yc.GuaIndex + yc.Line + now.Hour()) % 5
return &DailyTips{
ClothingIndex: 62 + (yc.GuaIndex % 31),
Clothing: clothing[i],
ColorNote: notes[i],
Palette: palettes[i],
Wellness: well[i],
Source: "fallback",
GuaName: yc.GuaName,
DayPart: yc.DayPart,
}
}
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
@@ -25,9 +26,11 @@ var allowedIcons = map[string]struct{}{
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
}
// Service serves homepage tool catalog.
// Service serves homepage tool catalog and daily tips.
type Service struct {
Repo *repository.HomeToolsRepo
Repo *repository.HomeToolsRepo
Profiles *repository.ProfileRepo
LLM *deepseek.Client
}
// ListPublic returns enabled tools.
@@ -30,6 +30,9 @@ type CreateInput struct {
BirthPlace *string
}
// ErrSelfExists is returned when creating a second self profile.
var ErrSelfExists = errors.New("self profile already exists")
// 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" {
@@ -38,6 +41,15 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
if in.BirthDate.IsZero() {
return nil, errors.New("birth_date required")
}
if in.Relation == "self" {
n, err := s.Repo.CountActiveSelf(ctx, userID)
if err != nil {
return nil, err
}
if n > 0 {
return nil, ErrSelfExists
}
}
name := in.DisplayName
if name == "" {
if in.Relation == "self" {
@@ -163,6 +163,9 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
// CreateSynastry builds a multi-chart synastry report for two profiles.
// asOf is the secondary-progression date (defaults to today CST when nil/zero).
func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) {
if profileAID == profileBID {
return nil, errors.New("合盘需要两个不同档案")
}
pa, err := s.Profiles.GetForUser(ctx, userID, profileAID)
if err != nil {
return nil, errors.New("profile a not found")
@@ -174,6 +177,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil
if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID {
return nil, errors.New("profile b not found")
}
} else if pb.Relation != "other" {
return nil, errors.New("合盘对象须为 TA 档案或附近的人")
}
ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace)
if err != nil {