feat(ECR-011): 昵称、首页贴士与档案 Self 唯一/合盘交叉校验
每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", h.Me)
|
||||
g.PATCH("/me", h.PatchMe)
|
||||
}
|
||||
|
||||
type authBody struct {
|
||||
@@ -97,6 +98,30 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
type patchMeBody struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// PatchMe handles PATCH /auth/me (update nickname).
|
||||
func (h *AuthHandler) PatchMe(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body patchMeBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
@@ -19,6 +20,7 @@ type HomeHandler struct {
|
||||
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/home")
|
||||
g.GET("/tools", h.Tools)
|
||||
g.GET("/daily-tips", h.DailyTips)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) Tools(c *gin.Context) {
|
||||
@@ -32,3 +34,13 @@ func (h *HomeHandler) Tools(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DailyTips(c *gin.Context) {
|
||||
uid, _ := middleware.UserIDFromContext(c)
|
||||
tips, err := h.Svc.DailyTips(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50031, "daily tips failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, tips)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +57,10 @@ func (h *ProfileHandler) Create(c *gin.Context) {
|
||||
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, profile.ErrSelfExists) {
|
||||
response.Fail(c, http.StatusConflict, 40902, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,11 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
Reports: reportRepo,
|
||||
Quotas: &repository.ImageCardRepo{Pool: pool},
|
||||
}
|
||||
homeSvc := &homesvc.Service{Repo: &repository.HomeToolsRepo{Pool: pool}}
|
||||
homeSvc := &homesvc.Service{
|
||||
Repo: &repository.HomeToolsRepo{Pool: pool},
|
||||
Profiles: profileRepo,
|
||||
LLM: llm,
|
||||
}
|
||||
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
||||
adminSvc := &adminsvc.Service{
|
||||
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ECR-011: one self per account; nickname patch; daily-tips envelope.
|
||||
func TestECR011ProfileIntegrityNicknameTips(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
key := mustRegister(t, r)
|
||||
|
||||
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
|
||||
}, key)
|
||||
self := decodeData[map[string]any](t, env.Data)
|
||||
selfID, _ := self["id"].(string)
|
||||
if selfID == "" {
|
||||
t.Fatal("missing self id")
|
||||
}
|
||||
|
||||
_, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "self", "birth_date": "1991-01-01", "display_name": "我2",
|
||||
}, key, 40902)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("second self want HTTP 409, got %d", status)
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||
"relation": "other", "birth_date": "1992-08-20", "display_name": "TA",
|
||||
}, key)
|
||||
other := decodeData[map[string]any](t, env.Data)
|
||||
otherID, _ := other["id"].(string)
|
||||
|
||||
_, key, status = doJSONExpect(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{
|
||||
"profile_id_a": selfID, "profile_id_b": selfID,
|
||||
}, key, 10000)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("same id synastry want HTTP 400, got %d", status)
|
||||
}
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{
|
||||
"profile_id_a": selfID, "profile_id_b": otherID,
|
||||
}, key)
|
||||
|
||||
env, key = doJSON(t, r, http.MethodPatch, "/api/v1/auth/me", map[string]any{
|
||||
"nickname": "心语岛",
|
||||
}, key)
|
||||
me := decodeData[map[string]any](t, env.Data)
|
||||
if me["nickname"] != "心语岛" {
|
||||
t.Fatalf("nickname want 心语岛 got %#v", me["nickname"])
|
||||
}
|
||||
|
||||
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/home/daily-tips", nil, key)
|
||||
tips := decodeData[map[string]any](t, env.Data)
|
||||
if tips["clothing"] == nil || tips["palette"] == nil || tips["wellness"] == nil {
|
||||
t.Fatalf("daily-tips missing fields: %#v", tips)
|
||||
}
|
||||
if _, ok := tips["clothing_index"].(float64); !ok {
|
||||
t.Fatalf("clothing_index missing: %#v", tips["clothing_index"])
|
||||
}
|
||||
src, _ := tips["source"].(string)
|
||||
if src != "llm" && src != "fallback" {
|
||||
t.Fatalf("source want llm|fallback got %q", src)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package nickname generates default display names: 意象词 + 场景词.
|
||||
package nickname
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// imageryWords: 100 soft, product-safe imagery stems (愈心谷 tone).
|
||||
var imageryWords = []string{
|
||||
"心语", "微光", "星愿", "清风", "暖心", "柔光", "浅梦", "静水", "云舒", "月白",
|
||||
"初晴", "晚风", "晨露", "细雨", "薄雾", "青禾", "橘光", "蜜语", "安然", "念初",
|
||||
"听澜", "拾光", "半夏", "秋迟", "春晓", "夏安", "冬暖", "暮色", "晓风", "星河",
|
||||
"流年", "浅唱", "低语", "轻舟", "远山", "近水", "素心", "明澄", "澄澈", "清欢",
|
||||
"淡然", "悠然", "恬静", "安宁", "和风", "暖阳", "软光", "碎金", "银杏", "白露",
|
||||
"谷雨", "立秋", "小满", "惊蛰", "霜降", "芒种", "处暑", "大寒", "小雪", "清明",
|
||||
"听雪", "望舒", "栖云", "栖月", "栖星", "栖心", "栖光", "栖梦", "栖风", "栖雨",
|
||||
"知夏", "知秋", "知春", "知暖", "知光", "知心", "知安", "知宁", "知澄", "知柔",
|
||||
"若水", "若云", "若风", "若光", "若梦", "若安", "若澄", "若暖", "若柔", "若心",
|
||||
"一念", "一息", "一寸", "一抹", "一缕", "一盏", "一程", "一湾", "一隅", "一席",
|
||||
}
|
||||
|
||||
// sceneWords: 100 place-like endings that read naturally after imagery stems.
|
||||
var sceneWords = []string{
|
||||
"岛", "谷", "屿", "里", "湾", "庭", "舍", "居", "苑", "港",
|
||||
"岸", "亭", "巷", "院", "轩", "斋", "庐", "屋", "堂", "阁",
|
||||
"台", "畔", "角", "隅", "间", "处", "边", "旁", "侧", "前",
|
||||
"后", "上", "下", "中", "外", "内", "东", "西", "南", "北",
|
||||
"林", "原", "坡", "丘", "岭", "峰", "溪", "泉", "湖", "海",
|
||||
"田", "野", "陌", "径", "路", "桥", "门", "窗", "廊", "阶",
|
||||
"坊", "市", "镇", "村", "庄", "乡", "社", "园", "圃", "畦",
|
||||
"巢", "窝", "坞", "渡", "津", "洲", "渚", "汀", "滩", "礁",
|
||||
"岚", "岫", "岩", "石", "沙", "草", "花", "叶", "枝", "根",
|
||||
"灯", "烛", "席", "枕", "案", "书", "笺", "墨", "茶", "盏",
|
||||
}
|
||||
|
||||
// Random returns 意象词+场景词, e.g. 心语岛.
|
||||
func Random() string {
|
||||
a := pick(len(imageryWords))
|
||||
b := pick(len(sceneWords))
|
||||
return imageryWords[a] + sceneWords[b]
|
||||
}
|
||||
|
||||
func pick(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(v.Int64())
|
||||
}
|
||||
|
||||
// Normalize trims and clamps nickname length (1–16 runes).
|
||||
func Normalize(s string) string {
|
||||
r := []rune(s)
|
||||
for len(r) > 0 && (r[0] == ' ' || r[0] == '\t' || r[0] == '\n') {
|
||||
r = r[1:]
|
||||
}
|
||||
for len(r) > 0 {
|
||||
last := r[len(r)-1]
|
||||
if last == ' ' || last == '\t' || last == '\n' {
|
||||
r = r[:len(r)-1]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if len(r) > 16 {
|
||||
r = r[:16]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package nickname
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestWordBankSize(t *testing.T) {
|
||||
if len(imageryWords) != 100 {
|
||||
t.Fatalf("imagery want 100 got %d", len(imageryWords))
|
||||
}
|
||||
if len(sceneWords) != 100 {
|
||||
t.Fatalf("scene want 100 got %d", len(sceneWords))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomFormat(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 50; i++ {
|
||||
n := Random()
|
||||
if utf8.RuneCountInString(n) < 2 {
|
||||
t.Fatalf("too short: %q", n)
|
||||
}
|
||||
seen[n] = true
|
||||
ok := false
|
||||
for _, im := range imageryWords {
|
||||
for _, sc := range sceneWords {
|
||||
if n == im+sc {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("not from bank: %q", n)
|
||||
}
|
||||
}
|
||||
if len(seen) < 5 {
|
||||
t.Fatalf("expected some variety, got %d unique", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
if got := Normalize(" 心语岛 "); got != "心语岛" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
long := strings.Repeat("啊", 20)
|
||||
if utf8.RuneCountInString(Normalize(long)) != 16 {
|
||||
t.Fatalf("clamp failed")
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,14 @@ func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nicknam
|
||||
return id, err
|
||||
}
|
||||
|
||||
// UpdateNickname sets users.nickname (non-empty).
|
||||
func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nickname string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
UPDATE users SET nickname=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, nickname)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchPassword updates stored password hash (open-login record).
|
||||
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
|
||||
@@ -34,6 +34,15 @@ func scanProfile(scan func(dest ...any) error) (*model.Profile, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CountActiveSelf returns non-deleted self profiles for the user.
|
||||
func (r *ProfileRepo) CountActiveSelf(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM profiles
|
||||
WHERE user_id=$1 AND relation='self' AND deleted_at IS NULL`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Create inserts a profile.
|
||||
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
|
||||
@@ -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 (1–16 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 为 55–95 整数;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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package yijing provides a deterministic hexagram seed from birth + local time.
|
||||
// It is a cultural rhythm seed for lifestyle tips — not fortune-telling.
|
||||
package yijing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context is the seed passed to LLM prompts.
|
||||
type Context struct {
|
||||
GuaIndex int // 1..64
|
||||
GuaName string // short label
|
||||
Line int // 1..6 changing line hint
|
||||
DayPart string // morning|noon|afternoon|evening|night
|
||||
NowLocal string
|
||||
BirthDate string
|
||||
BirthTime string
|
||||
}
|
||||
|
||||
var guaNames = []string{
|
||||
"乾", "坤", "屯", "蒙", "需", "讼", "师", "比",
|
||||
"小畜", "履", "泰", "否", "同人", "大有", "谦", "豫",
|
||||
"随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
|
||||
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒",
|
||||
"遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解",
|
||||
"损", "益", "夬", "姤", "萃", "升", "困", "井",
|
||||
"革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅",
|
||||
"巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济",
|
||||
}
|
||||
|
||||
// Seed builds hexagram context from birth date/time and now (Asia/Shanghai preferred).
|
||||
func Seed(birthDate string, birthTime *string, now time.Time) Context {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
loc = time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
now = now.In(loc)
|
||||
|
||||
bt := "12:00"
|
||||
if birthTime != nil && *birthTime != "" {
|
||||
bt = *birthTime
|
||||
}
|
||||
|
||||
sum := hash(birthDate) + hash(bt) + hash(now.Format("2006-01-02")) + now.Hour()*17 + now.Minute()/10
|
||||
gua := (sum % 64) + 1
|
||||
line := (sum/7)%6 + 1
|
||||
name := guaNames[gua-1]
|
||||
|
||||
return Context{
|
||||
GuaIndex: gua,
|
||||
GuaName: name,
|
||||
Line: line,
|
||||
DayPart: dayPart(now.Hour()),
|
||||
NowLocal: now.Format("2006-01-02 15:04"),
|
||||
BirthDate: birthDate,
|
||||
BirthTime: bt,
|
||||
}
|
||||
}
|
||||
|
||||
// PromptLine is a compact Chinese summary for the model.
|
||||
func (c Context) PromptLine() string {
|
||||
return fmt.Sprintf(
|
||||
"出生 %s %s;此刻 %s(时段 %s);卦象种子:%s(第%d卦)· 动爻%d",
|
||||
c.BirthDate, c.BirthTime, c.NowLocal, c.DayPart, c.GuaName, c.GuaIndex, c.Line,
|
||||
)
|
||||
}
|
||||
|
||||
func dayPart(h int) string {
|
||||
switch {
|
||||
case h < 6:
|
||||
return "night"
|
||||
case h < 11:
|
||||
return "morning"
|
||||
case h < 14:
|
||||
return "noon"
|
||||
case h < 18:
|
||||
return "afternoon"
|
||||
default:
|
||||
return "evening"
|
||||
}
|
||||
}
|
||||
|
||||
func hash(s string) int {
|
||||
n := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
n = n*131 + int(s[i])
|
||||
}
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package yijing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSeedStableSameInputs(t *testing.T) {
|
||||
bt := "10:30"
|
||||
now := time.Date(2026, 8, 11, 19, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
a := Seed("1990-05-12", &bt, now)
|
||||
b := Seed("1990-05-12", &bt, now)
|
||||
if a.GuaIndex != b.GuaIndex || a.GuaName != b.GuaName || a.Line != b.Line {
|
||||
t.Fatalf("expected stable seed, got %+v vs %+v", a, b)
|
||||
}
|
||||
if a.GuaIndex < 1 || a.GuaIndex > 64 {
|
||||
t.Fatalf("gua out of range: %d", a.GuaIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedChangesWithHour(t *testing.T) {
|
||||
now1 := time.Date(2026, 8, 11, 9, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
now2 := time.Date(2026, 8, 11, 18, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
a := Seed("1990-05-12", nil, now1)
|
||||
b := Seed("1990-05-12", nil, now2)
|
||||
if a.DayPart == b.DayPart {
|
||||
t.Fatalf("expected different day parts, both %s", a.DayPart)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_profiles_one_self_per_user;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- One active self profile per user; soft-delete older duplicates first.
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, id DESC) AS rn
|
||||
FROM profiles
|
||||
WHERE relation = 'self' AND deleted_at IS NULL
|
||||
)
|
||||
UPDATE profiles p
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
FROM ranked r
|
||||
WHERE p.id = r.id AND r.rn > 1 AND p.deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_profiles_one_self_per_user
|
||||
ON profiles (user_id)
|
||||
WHERE relation = 'self' AND deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user