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" "github.com/yuxingu/digital-psychology/apps/api/internal/javabridge" 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" "github.com/yuxingu/digital-psychology/apps/api/internal/wechat" ) // 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 WeChat wechat.Client WeChatApp string Java *javabridge.Client } // 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 (1–16 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) } // WeChatLogin signs in with mini-program js_code + phone encryptedData. func (s *Service) WeChatLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, code, encryptedData, iv string) (*SessionResult, error) { if s.WeChat == nil { return nil, errors.New("微信登录未配置") } sess, err := s.WeChat.Code2Session(ctx, code) if err != nil { return nil, err } phone, err := wechat.DecryptPhone(sess.SessionKey, encryptedData, iv, s.WeChatApp) if err != nil { return nil, err } acc, err := s.Repo.GetByWxOpenid(ctx, sess.OpenID) if err == nil { if acc.Status != "active" { return nil, ErrAccountRestricted } _ = s.Repo.BindWeChat(ctx, acc.ID, sess.OpenID, sess.UnionID, phone) if deviceKey != "" { _ = s.Repo.BindDevice(ctx, deviceKey, acc.ID) } return s.finishWeChat(ctx, acc.ID, phone, acc.Nickname, false, sess, deviceKey) } if !errors.Is(err, pgx.ErrNoRows) { return nil, err } byPhone, err := s.Repo.GetByPhone(ctx, phone) if err == nil { if byPhone.Status != "active" { return nil, ErrAccountRestricted } _ = s.Repo.BindWeChat(ctx, byPhone.ID, sess.OpenID, sess.UnionID, phone) if deviceKey != "" { _ = s.Repo.BindDevice(ctx, deviceKey, byPhone.ID) } nick := byPhone.Nickname if nick == "" { nick = nickgen.Random() _ = s.Repo.UpdateNickname(ctx, byPhone.ID, nick) } return s.finishWeChat(ctx, byPhone.ID, phone, nick, false, sess, deviceKey) } if !errors.Is(err, pgx.ErrNoRows) { return nil, err } hash, err := bcrypt.GenerateFromPassword([]byte("wx:"+sess.OpenID), bcrypt.DefaultCost) if err != nil { return nil, err } nick := nickgen.Random() uid := deviceUserID cur, curErr := s.Repo.GetAccount(ctx, deviceUserID) if curErr == nil && cur.Phone == "" && cur.WxOpenID == "" { if cur.Status != "active" { return nil, ErrAccountRestricted } if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, string(hash), nick); err != nil { return nil, errors.New("登录失败,请重试") } _ = s.Repo.BindWeChat(ctx, deviceUserID, sess.OpenID, sess.UnionID, phone) } else { uid, err = s.Repo.CreateUserWithWeChat(ctx, phone, string(hash), nick, sess.OpenID, sess.UnionID) if err != nil { return nil, errors.New("登录失败,请重试") } } if deviceKey != "" { _ = s.Repo.BindDevice(ctx, deviceKey, uid) } return s.finishWeChat(ctx, uid, phone, nick, true, sess, deviceKey) } func (s *Service) finishWeChat(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool, sess wechat.Session, _ string) (*SessionResult, error) { res, err := s.issue(ctx, userID, phone, nickname, isNew) if err != nil { return nil, err } if s.Java != nil && s.Java.Enabled() { if tok, jerr := s.Java.IssueMiniApp(ctx, sess.OpenID, sess.UnionID, phone); jerr == nil && tok != nil { _ = s.Repo.SetSessionJavaToken(ctx, res.Token, tok.AccessToken) if tok.MemberID > 0 { _ = s.Repo.SetJavaPlatformUserID(ctx, userID, tok.MemberID) } } } return res, 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 s.Java != nil && s.Java.Enabled() { if jt, err := s.Repo.JavaTokenByGoToken(ctx, token); err == nil && jt != "" { s.Java.Logout(ctx, jt) } } 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) }