Files
doc_ai_backed/internal/service/user_service.go
yoge a29936f31c feat: add email verification code for registration and optimize email service
- Add POST /user/email/code endpoint to send 6-digit verification code
- Require email code verification before completing registration
- Add email code cache with 10min TTL and 5/day send rate limit
- Fix nil client guard, TLS conn leak, domain parsing, and Resend error body in email pkg
- Deploy via ssh inline command using current branch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:47:06 +08:00

332 lines
10 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 service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/http"
"net/url"
"gitea.com/texpixel/document_ai/config"
model "gitea.com/texpixel/document_ai/internal/model/user"
"gitea.com/texpixel/document_ai/internal/storage/cache"
"gitea.com/texpixel/document_ai/internal/storage/dao"
"gitea.com/texpixel/document_ai/pkg/common"
"gitea.com/texpixel/document_ai/pkg/email"
"gitea.com/texpixel/document_ai/pkg/log"
"gitea.com/texpixel/document_ai/pkg/sms"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
userDao *dao.UserDao
}
func NewUserService() *UserService {
return &UserService{
userDao: dao.NewUserDao(),
}
}
func (svc *UserService) GetSmsCode(ctx context.Context, phone string) (string, error) {
limit, err := cache.GetUserSendSmsLimit(ctx, phone)
if err != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "get user send sms limit error", "error", err)
return "", err
}
if limit >= cache.UserSendSmsLimitCount {
return "", errors.New("sms code send limit reached")
}
user, err := svc.userDao.GetByPhone(dao.DB.WithContext(ctx), phone)
if err != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "get user error", "error", err)
return "", err
}
if user == nil {
user = &dao.User{Phone: phone}
err = svc.userDao.Create(dao.DB.WithContext(ctx), user)
if err != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "create user error", "error", err)
return "", err
}
}
code := fmt.Sprintf("%06d", rand.Intn(1000000))
err = sms.SendMessage(&sms.SendSmsRequest{PhoneNumbers: phone, SignName: sms.Signature, TemplateCode: sms.TemplateCode, TemplateParam: fmt.Sprintf(sms.TemplateParam, code)})
if err != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "send message error", "error", err)
return "", err
}
cacheErr := cache.SetUserSmsCode(ctx, phone, code)
if cacheErr != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "set user sms code error", "error", cacheErr)
}
cacheErr = cache.SetUserSendSmsLimit(ctx, phone)
if cacheErr != nil {
log.Error(ctx, "func", "GetSmsCode", "msg", "set user send sms limit error", "error", cacheErr)
}
return code, nil
}
func (svc *UserService) VerifySmsCode(ctx context.Context, phone, code string) (uid int64, err error) {
user, err := svc.userDao.GetByPhone(dao.DB.WithContext(ctx), phone)
if err != nil {
log.Error(ctx, "func", "VerifySmsCode", "msg", "get user error", "error", err, "phone", phone)
return 0, err
}
if user == nil {
log.Error(ctx, "func", "VerifySmsCode", "msg", "user not found", "phone", phone)
return 0, errors.New("user not found")
}
storedCode, err := cache.GetUserSmsCode(ctx, phone)
if err != nil {
log.Error(ctx, "func", "VerifySmsCode", "msg", "get user sms code error", "error", err)
return 0, err
}
if storedCode != code {
log.Error(ctx, "func", "VerifySmsCode", "msg", "invalid sms code", "phone", phone, "code", code, "storedCode", storedCode)
return 0, errors.New("invalid sms code")
}
cacheErr := cache.DeleteUserSmsCode(ctx, phone)
if cacheErr != nil {
log.Error(ctx, "func", "VerifySmsCode", "msg", "delete user sms code error", "error", cacheErr)
}
return user.ID, nil
}
func (svc *UserService) GetUserInfo(ctx context.Context, uid int64) (*dao.User, error) {
user, err := svc.userDao.GetByID(dao.DB.WithContext(ctx), uid)
if err != nil {
log.Error(ctx, "func", "GetUserInfo", "msg", "get user error", "error", err)
return nil, err
}
if user == nil {
log.Warn(ctx, "func", "GetUserInfo", "msg", "user not found", "uid", uid)
return &dao.User{}, nil
}
return user, nil
}
func (svc *UserService) SendEmailCode(ctx context.Context, emailAddr string) error {
limit, err := cache.GetUserSendEmailLimit(ctx, emailAddr)
if err != nil {
log.Error(ctx, "func", "SendEmailCode", "msg", "get send email limit error", "error", err)
return err
}
if limit >= cache.UserSendEmailLimitCount {
return common.ErrEmailSendLimit
}
code := fmt.Sprintf("%06d", rand.Intn(1000000))
subject := "TexPixel 邮箱验证码"
body := fmt.Sprintf(`<p>您的验证码为:<strong>%s</strong>10分钟内有效请勿泄露。</p>`, code)
if err = email.Send(ctx, emailAddr, subject, body); err != nil {
log.Error(ctx, "func", "SendEmailCode", "msg", "send email error", "error", err)
return err
}
if cacheErr := cache.SetUserEmailCode(ctx, emailAddr, code); cacheErr != nil {
log.Error(ctx, "func", "SendEmailCode", "msg", "set email code error", "error", cacheErr)
}
if cacheErr := cache.SetUserSendEmailLimit(ctx, emailAddr); cacheErr != nil {
log.Error(ctx, "func", "SendEmailCode", "msg", "set send email limit error", "error", cacheErr)
}
return nil
}
func (svc *UserService) RegisterByEmail(ctx context.Context, emailAddr, password, code string) (uid int64, err error) {
storedCode, err := cache.GetUserEmailCode(ctx, emailAddr)
if err != nil {
log.Error(ctx, "func", "RegisterByEmail", "msg", "get email code error", "error", err)
return 0, err
}
if storedCode == "" || storedCode != code {
log.Warn(ctx, "func", "RegisterByEmail", "msg", "invalid email code", "email", emailAddr)
return 0, common.ErrEmailCodeError
}
existingUser, err := svc.userDao.GetByEmail(dao.DB.WithContext(ctx), emailAddr)
if err != nil {
log.Error(ctx, "func", "RegisterByEmail", "msg", "get user by email error", "error", err)
return 0, err
}
if existingUser != nil {
log.Warn(ctx, "func", "RegisterByEmail", "msg", "email already registered", "email", emailAddr)
return 0, common.ErrEmailExists
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Error(ctx, "func", "RegisterByEmail", "msg", "hash password error", "error", err)
return 0, err
}
user := &dao.User{
Email: emailAddr,
Password: string(hashedPassword),
}
if err = svc.userDao.Create(dao.DB.WithContext(ctx), user); err != nil {
log.Error(ctx, "func", "RegisterByEmail", "msg", "create user error", "error", err)
return 0, err
}
if cacheErr := cache.DeleteUserEmailCode(ctx, emailAddr); cacheErr != nil {
log.Error(ctx, "func", "RegisterByEmail", "msg", "delete email code error", "error", cacheErr)
}
return user.ID, nil
}
func (svc *UserService) LoginByEmail(ctx context.Context, email, password string) (uid int64, err error) {
user, err := svc.userDao.GetByEmail(dao.DB.WithContext(ctx), email)
if err != nil {
log.Error(ctx, "func", "LoginByEmail", "msg", "get user by email error", "error", err)
return 0, err
}
if user == nil {
log.Warn(ctx, "func", "LoginByEmail", "msg", "user not found", "email", email)
return 0, common.ErrEmailNotFound
}
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
log.Warn(ctx, "func", "LoginByEmail", "msg", "password mismatch", "email", email)
return 0, common.ErrPasswordMismatch
}
return user.ID, nil
}
type googleTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
func (svc *UserService) googleHTTPClient() *http.Client {
if config.GlobalConfig.Google.Proxy == "" {
return &http.Client{}
}
proxyURL, err := url.Parse(config.GlobalConfig.Google.Proxy)
if err != nil {
return &http.Client{}
}
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
}
func (svc *UserService) ExchangeGoogleCodeAndGetUserInfo(ctx context.Context, clientID, clientSecret, code, redirectURI string) (*model.GoogleUserInfo, error) {
tokenURL := "https://oauth2.googleapis.com/token"
formData := url.Values{
"client_id": {clientID},
"client_secret": {clientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
}
client := svc.googleHTTPClient()
resp, err := client.PostForm(tokenURL, formData)
if err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "exchange code failed", "error", err)
return nil, err
}
defer resp.Body.Close()
var tokenResp googleTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "decode token response failed", "error", err)
return nil, err
}
if tokenResp.AccessToken == "" {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "no access token in response")
return nil, errors.New("no access token in response")
}
userInfo, err := svc.getGoogleUserInfo(ctx, tokenResp.AccessToken)
if err != nil {
log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "get user info failed", "error", err)
return nil, err
}
return &model.GoogleUserInfo{
ID: userInfo.ID,
Email: userInfo.Email,
Name: userInfo.Name,
}, nil
}
func (svc *UserService) getGoogleUserInfo(ctx context.Context, accessToken string) (*model.GoogleUserInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := svc.googleHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var userInfo model.GoogleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}
func (svc *UserService) FindOrCreateGoogleUser(ctx context.Context, userInfo *model.GoogleUserInfo) (uid int64, err error) {
existingUser, err := svc.userDao.GetByGoogleID(dao.DB.WithContext(ctx), userInfo.ID)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by google id error", "error", err)
return 0, err
}
if existingUser != nil {
return existingUser.ID, nil
}
existingUser, err = svc.userDao.GetByEmail(dao.DB.WithContext(ctx), userInfo.Email)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by email error", "error", err)
return 0, err
}
if existingUser != nil {
existingUser.GoogleID = userInfo.ID
err = svc.userDao.Update(dao.DB.WithContext(ctx), existingUser)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "update user google id error", "error", err)
return 0, err
}
return existingUser.ID, nil
}
user := &dao.User{
Email: userInfo.Email,
GoogleID: userInfo.ID,
Username: userInfo.Name,
}
err = svc.userDao.Create(dao.DB.WithContext(ctx), user)
if err != nil {
log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "create user error", "error", err)
return 0, err
}
return user.ID, nil
}