Files
jackyu66gitandCursor 7155b8b53a feat(api): 接入微信登录并原生实现咨询域(ECR-049/050)
小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 00:26:29 +08:00

334 lines
9.7 KiB
Go
Raw Permalink 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"
"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 (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)
}
// 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)
}