每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"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"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// Me is the public account payload.
|
||
type Me struct {
|
||
ID string `json:"id"`
|
||
Phone string `json:"phone"`
|
||
Nickname string `json:"nickname"`
|
||
}
|
||
|
||
// SessionResult is returned after register/login.
|
||
type SessionResult struct {
|
||
Token string `json:"token"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
User Me `json:"user"`
|
||
}
|
||
|
||
// 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 = nickgen.Normalize(nickIn)
|
||
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 {
|
||
_ = 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)
|
||
}
|
||
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 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)
|
||
}
|
||
|
||
// 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
|
||
// 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 &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, 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) (*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
|
||
}
|
||
return &SessionResult{
|
||
Token: tok, ExpiresAt: exp,
|
||
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
|
||
}, nil
|
||
}
|
||
|
||
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)
|
||
}
|