Files
digital-psychology/apps/api/internal/service/auth/service.go
T
jackyu66gitandCursor 4889ff5916 merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:45:53 +08:00

233 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"mime/multipart"
"strings"
"time"
"github.com/google/uuid"
"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
AvatarDir string
}
// Me is the public account payload.
type Me struct {
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// SessionResult is returned after register/login.
type SessionResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User Me `json:"user"`
IsNew bool `json:"is_new"`
}
// ErrAccountRestricted is returned when UserStatus is not active.
var ErrAccountRestricted = errors.New("账户已受限")
// 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, nickIn string) (*SessionResult, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return nil, errors.New("请填写手机号")
}
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
}
hashStr := string(hash)
acc, err := s.Repo.GetByPhone(ctx, phone)
if err == nil {
if acc.Status != "active" {
return nil, ErrAccountRestricted
}
_ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
}
nick := acc.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, false)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
nick := nickIn
if nick == "" {
nick = nickgen.Random()
}
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
uid := deviceUserID
if curErr == nil && cur.Phone == "" {
if cur.Status != "active" {
return nil, ErrAccountRestricted
}
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, nick)
if err != nil {
return nil, errors.New("登录失败,请重试")
}
}
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
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, 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
}
if acc.Phone == "" {
return nil, errors.New("未登录")
}
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
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
if token != "" {
if err := s.Repo.RevokeSession(ctx, token); err != nil {
return err
}
}
return s.Repo.RebindDeviceAnonymous(ctx, deviceKey)
}
// 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 toMe(acc), 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, 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,
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
}
return p[:3] + "****" + p[7:]
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}