Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
309
internal/service/auth.go
Normal file
309
internal/service/auth.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// Package service 提供用户注册/登录与验证码服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"stock-analysis/internal/model"
|
||||
)
|
||||
|
||||
// AuthService 用户认证服务。
|
||||
type AuthService struct {
|
||||
store AuthStore
|
||||
// 邮件发送器,未配置时使用控制台打印。
|
||||
mailer Mailer
|
||||
// 验证码有效期。
|
||||
codeTTL time.Duration
|
||||
|
||||
tokenMu sync.RWMutex
|
||||
tokens map[string]int64 // token -> userID
|
||||
}
|
||||
|
||||
// AuthStore 定义认证所需的持久化接口。
|
||||
type AuthStore interface {
|
||||
SaveUser(ctx context.Context, u model.User) (int64, error)
|
||||
GetUserByID(ctx context.Context, id int64) (*model.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
|
||||
SaveVerifyCode(ctx context.Context, v model.VerifyCode) error
|
||||
GetLatestVerifyCode(ctx context.Context, email, purpose string) (*model.VerifyCode, error)
|
||||
MarkVerifyCodeUsed(ctx context.Context, email, code, purpose string) error
|
||||
}
|
||||
|
||||
// Mailer 邮件发送接口。
|
||||
type Mailer interface {
|
||||
SendCode(email, code string) error
|
||||
}
|
||||
|
||||
// consoleMailer 默认邮件发送器,仅打印到控制台。
|
||||
type consoleMailer struct{}
|
||||
|
||||
func (consoleMailer) SendCode(email, code string) error {
|
||||
fmt.Printf("[验证码] 邮箱: %s 验证码: %s\n", email, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务。
|
||||
func NewAuthService(store AuthStore) *AuthService {
|
||||
return &AuthService{
|
||||
store: store,
|
||||
mailer: consoleMailer{},
|
||||
codeTTL: 10 * time.Minute,
|
||||
tokens: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// SetMailer 设置外部邮件发送器。
|
||||
func (s *AuthService) SetMailer(m Mailer) { s.mailer = m }
|
||||
|
||||
// SendVerifyCode 发送邮箱验证码。
|
||||
func (s *AuthService) SendVerifyCode(ctx context.Context, email, purpose string) error {
|
||||
email = normalizeEmail(email)
|
||||
if !isValidEmail(email) {
|
||||
return fmt.Errorf("邮箱格式不正确")
|
||||
}
|
||||
if purpose != "register" && purpose != "login" && purpose != "reset" {
|
||||
purpose = "register"
|
||||
}
|
||||
|
||||
code := randomCode(6)
|
||||
v := model.VerifyCode{
|
||||
Email: email,
|
||||
Code: code,
|
||||
Purpose: purpose,
|
||||
ExpiresAt: time.Now().Add(s.codeTTL).Unix(),
|
||||
}
|
||||
if err := s.store.SaveVerifyCode(ctx, v); err != nil {
|
||||
return fmt.Errorf("保存验证码失败: %w", err)
|
||||
}
|
||||
if s.mailer != nil {
|
||||
if err := s.mailer.SendCode(email, code); err != nil {
|
||||
return fmt.Errorf("发送验证码失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register 邮箱+验证码注册,包含用户名。
|
||||
func (s *AuthService) Register(ctx context.Context, username, email, code, password string) (*model.User, error) {
|
||||
email = normalizeEmail(email)
|
||||
if !isValidEmail(email) {
|
||||
return nil, fmt.Errorf("邮箱格式不正确")
|
||||
}
|
||||
if len(password) < 6 {
|
||||
return nil, fmt.Errorf("密码长度不能少于6位")
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("用户名不能为空")
|
||||
}
|
||||
|
||||
if err := s.verifyCode(ctx, email, code, "register"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, fmt.Errorf("该邮箱已注册")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
u := model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
id, err := s.store.SaveUser(ctx, u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("保存用户失败: %w", err)
|
||||
}
|
||||
u.ID = id
|
||||
|
||||
// 标记验证码已使用。
|
||||
_ = s.store.MarkVerifyCodeUsed(ctx, email, code, "register")
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Login 邮箱+验证码登录。
|
||||
func (s *AuthService) Login(ctx context.Context, email, code string) (*model.User, string, error) {
|
||||
email = normalizeEmail(email)
|
||||
if !isValidEmail(email) {
|
||||
return nil, "", fmt.Errorf("邮箱格式不正确")
|
||||
}
|
||||
|
||||
if err := s.verifyCode(ctx, email, code, "login"); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
u, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if u == nil {
|
||||
return nil, "", fmt.Errorf("该邮箱未注册")
|
||||
}
|
||||
|
||||
_ = s.store.MarkVerifyCodeUsed(ctx, email, code, "login")
|
||||
token := s.issueToken(u.ID)
|
||||
return u, token, nil
|
||||
}
|
||||
|
||||
// LoginByUsername 用户名+密码登录。
|
||||
func (s *AuthService) LoginByUsername(ctx context.Context, username, password string) (*model.User, string, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, "", fmt.Errorf("用户名不能为空")
|
||||
}
|
||||
u, err := s.store.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if u == nil {
|
||||
return nil, "", fmt.Errorf("用户名或密码错误")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, "", fmt.Errorf("用户名或密码错误")
|
||||
}
|
||||
token := s.issueToken(u.ID)
|
||||
return u, token, nil
|
||||
}
|
||||
|
||||
// EnsureAdmin 确保 admin 用户存在,不存在则创建。
|
||||
func (s *AuthService) EnsureAdmin(ctx context.Context) error {
|
||||
u, err := s.store.GetUserByUsername(ctx, "admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询admin: %w", err)
|
||||
}
|
||||
if u != nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加密admin密码: %w", err)
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
admin := model.User{
|
||||
Username: "admin",
|
||||
Email: "admin@stock.local",
|
||||
PasswordHash: string(hash),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, err := s.store.SaveUser(ctx, admin); err != nil {
|
||||
return fmt.Errorf("创建admin: %w", err)
|
||||
}
|
||||
log.Printf("[auth] admin 管理员账户已创建(用户名: admin, 密码: 123456)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// issueToken 生成并保存会话 token。
|
||||
func (s *AuthService) issueToken(userID int64) string {
|
||||
token, _ := RandomToken(32)
|
||||
s.tokenMu.Lock()
|
||||
s.tokens[token] = userID
|
||||
s.tokenMu.Unlock()
|
||||
return token
|
||||
}
|
||||
|
||||
// ValidateToken 校验 token 并返回对应用户。
|
||||
func (s *AuthService) ValidateToken(ctx context.Context, token string) (*model.User, error) {
|
||||
s.tokenMu.RLock()
|
||||
userID, ok := s.tokens[token]
|
||||
s.tokenMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("未登录或登录已过期")
|
||||
}
|
||||
u, err := s.store.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if u == nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Logout 注销指定 token。
|
||||
func (s *AuthService) Logout(token string) {
|
||||
s.tokenMu.Lock()
|
||||
delete(s.tokens, token)
|
||||
s.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
// verifyCode 校验验证码。
|
||||
func (s *AuthService) verifyCode(ctx context.Context, email, code, purpose string) error {
|
||||
stored, err := s.store.GetLatestVerifyCode(ctx, email, purpose)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询验证码失败: %w", err)
|
||||
}
|
||||
if stored == nil {
|
||||
return fmt.Errorf("验证码不存在,请先获取")
|
||||
}
|
||||
if stored.Used {
|
||||
return fmt.Errorf("验证码已使用")
|
||||
}
|
||||
if time.Now().Unix() > stored.ExpiresAt {
|
||||
return fmt.Errorf("验证码已过期")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(stored.Code), []byte(code)) != 1 {
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
if !emailRegex.MatchString(email) {
|
||||
return false
|
||||
}
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// randomCode 生成指定长度的数字验证码。
|
||||
func randomCode(length int) string {
|
||||
const digits = "0123456789"
|
||||
var b strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
||||
b.WriteByte(digits[n.Int64()])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RandomToken 生成随机 token。
|
||||
func RandomToken(length int) (string, error) {
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
Reference in New Issue
Block a user