Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

309
internal/service/auth.go Normal file
View 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
}