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:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+57 -14
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"mime/multipart"
"strings"
"time"
@@ -12,21 +13,25 @@ import (
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// 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
Repo *repository.AuthRepo
AvatarDir string
}
// Me is the public account payload.
type Me struct {
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// SessionResult is returned after register/login.
@@ -34,6 +39,7 @@ type SessionResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User Me `json:"user"`
IsNew bool `json:"is_new"`
}
// Register upgrades or opens an account (same open rules as Login).
@@ -52,7 +58,14 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if phone == "" {
return nil, errors.New("请填写手机号")
}
nickIn = nickgen.Normalize(nickIn)
nickIn = strings.TrimSpace(nickIn)
if nickIn != "" {
n, err := textsafe.Check(textsafe.Nickname, nickIn)
if err != nil {
return nil, err
}
nickIn = n
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -73,7 +86,7 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
}
return s.issue(ctx, acc.ID, acc.Phone, nick)
return s.issue(ctx, acc.ID, acc.Phone, nick, false)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
@@ -99,18 +112,23 @@ 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, nick)
return s.issue(ctx, uid, phone, nick, true)
}
// 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("请填写昵称")
nick, err := textsafe.Check(textsafe.Nickname, nick)
if err != nil {
return nil, err
}
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
return nil, err
}
return s.Me(ctx, userID)
}
// UpdateAvatar stores profile photo and returns updated me.
func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multipart.FileHeader) (*Me, error) {
acc, err := s.Repo.GetAccount(ctx, userID)
if err != nil {
return nil, err
@@ -118,7 +136,18 @@ func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick str
if acc.Phone == "" {
return nil, errors.New("未登录")
}
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
dir := s.AvatarDir
if dir == "" {
dir = "data/avatars"
}
path, err := avatar.Store(dir, userID, fh)
if err != nil {
return nil, err
}
if err := s.Repo.UpdateAvatarURL(ctx, userID, path); err != nil {
return nil, err
}
return s.Me(ctx, userID)
}
// Logout revokes the current bearer session and unbinds the device from the account
@@ -141,7 +170,7 @@ func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
if acc.Phone == "" {
return nil, errors.New("未登录")
}
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
return toMe(acc), nil
}
// ResolveSessionUser returns user id for a live token.
@@ -154,18 +183,32 @@ func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, err
return s.Repo.IsRegistered(ctx, userID)
}
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool) (*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
}
me := &Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname}
if acc, err := s.Repo.GetAccount(ctx, userID); err == nil {
me = toMe(acc)
}
return &SessionResult{
Token: tok, ExpiresAt: exp,
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
User: *me,
IsNew: isNew,
}, nil
}
func toMe(acc *repository.AccountRow) *Me {
return &Me{
ID: acc.ID.String(),
Phone: maskPhone(acc.Phone),
Nickname: acc.Nickname,
AvatarURL: acc.AvatarURL,
}
}
func maskPhone(p string) string {
if len(p) != 11 {
return p