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
}

View File

@@ -0,0 +1,153 @@
package service
import (
"context"
"os"
"testing"
"stock-analysis/internal/model"
"stock-analysis/internal/storage/sqlite"
)
type testMailer struct {
lastEmail string
lastCode string
}
func (m *testMailer) SendCode(email, code string) error {
m.lastEmail = email
m.lastCode = code
return nil
}
func TestAuthService(t *testing.T) {
ctx := context.Background()
dbPath := "test_auth.db"
_ = os.Remove(dbPath)
defer os.Remove(dbPath)
store, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer store.Close()
svc := NewAuthService(store)
mailer := &testMailer{}
svc.SetMailer(mailer)
email := "Test@Example.COM "
normalized := "test@example.com"
username := "testuser"
// 1. 发送注册验证码
if err := svc.SendVerifyCode(ctx, email, "register"); err != nil {
t.Fatalf("send register code: %v", err)
}
if mailer.lastEmail != normalized {
t.Fatalf("email not normalized: got %s want %s", mailer.lastEmail, normalized)
}
if len(mailer.lastCode) != 6 {
t.Fatalf("code length invalid: %s", mailer.lastCode)
}
// 2. 验证码注册(含用户名)
u, err := svc.Register(ctx, username, email, mailer.lastCode, "123456")
if err != nil {
t.Fatalf("register: %v", err)
}
if u.Email != normalized {
t.Fatalf("registered email: got %s want %s", u.Email, normalized)
}
if u.Username != username {
t.Fatalf("registered username: got %s want %s", u.Username, username)
}
// 3. 重复注册应失败
if _, err := svc.Register(ctx, username, email, mailer.lastCode, "123456"); err == nil {
t.Fatal("duplicate register should fail")
}
// 4. 发送登录验证码
if err := svc.SendVerifyCode(ctx, email, "login"); err != nil {
t.Fatalf("send login code: %v", err)
}
// 5. 验证码登录
logged, token, err := svc.Login(ctx, email, mailer.lastCode)
if err != nil {
t.Fatalf("login: %v", err)
}
if logged.ID != u.ID {
t.Fatal("login user id mismatch")
}
if token == "" {
t.Fatal("login token empty")
}
// 6. 用户名+密码登录
logged2, token2, err := svc.LoginByUsername(ctx, username, "123456")
if err != nil {
t.Fatalf("login by username: %v", err)
}
if logged2.ID != u.ID {
t.Fatal("username login user id mismatch")
}
if token2 == "" {
t.Fatal("username login token empty")
}
// 7. token 校验
validated, err := svc.ValidateToken(ctx, token2)
if err != nil {
t.Fatalf("validate token: %v", err)
}
if validated.ID != u.ID {
t.Fatal("validated user id mismatch")
}
// 8. 退出后 token 失效
svc.Logout(token2)
if _, err := svc.ValidateToken(ctx, token2); err == nil {
t.Fatal("token should be invalid after logout")
}
// 9. 密码错误
if _, _, err := svc.LoginByUsername(ctx, username, "wrong"); err == nil {
t.Fatal("wrong password should fail")
}
// 10. 验证码过期/错误
if _, _, err := svc.Login(ctx, email, "000000"); err == nil {
t.Fatal("used/wrong code should fail")
}
// 11. 不存在用户
if _, _, err := svc.Login(ctx, "notfound@example.com", "123456"); err == nil {
t.Fatal("nonexistent user should fail")
}
// 12. EnsureAdmin 创建 admin
if err := svc.EnsureAdmin(ctx); err != nil {
t.Fatalf("ensure admin: %v", err)
}
// 第二次调用应不报错(幂等)
if err := svc.EnsureAdmin(ctx); err != nil {
t.Fatalf("ensure admin idempotent: %v", err)
}
// admin 可以登录
admin, adminToken, err := svc.LoginByUsername(ctx, "admin", "123456")
if err != nil {
t.Fatalf("admin login: %v", err)
}
if admin.Username != "admin" {
t.Fatal("admin username mismatch")
}
if adminToken == "" {
t.Fatal("admin token empty")
}
// 验证 model 类型
_ = model.User{}
_ = model.VerifyCode{}
}

View File

@@ -0,0 +1,76 @@
// Package service 定义业务逻辑层,位于 MVC 架构中 Controller 与 Model 之间。
// 本层封装领域业务(行情、策略、回测),不感知 HTTP 与具体数据源实现。
package service
import (
"context"
"fmt"
"time"
"stock-analysis/internal/datasource"
"stock-analysis/internal/model"
)
// MarketService 提供行情查询业务。
type MarketService struct {
ds datasource.MarketDataSource
}
// NewMarketService 创建行情服务。
func NewMarketService(ds datasource.MarketDataSource) *MarketService {
return &MarketService{ds: ds}
}
// ListConvertibles 返回全量可转债实时行情(过滤已退市转债)。
func (s *MarketService) ListConvertibles(ctx context.Context) ([]model.ConvertibleBond, error) {
all, err := s.ds.FetchConvertibles(ctx)
if err != nil {
return nil, err
}
// 过滤掉已退市的转债:退市后东财接口返回的价格为 0。
var active []model.ConvertibleBond
for _, b := range all {
if b.Price > 0 {
active = append(active, b)
}
}
return active, nil
}
// GetDailyKline 返回指定可转债的日K线(自 beg 至今)。
func (s *MarketService) GetDailyKline(ctx context.Context, code, beg string) ([]model.Kline, error) {
// klt=101 为日K,beg/end 格式 20060102;end 取极大值表示拉取至今。
return s.ds.FetchKline(ctx, code, "101", beg, "20500101")
}
// FetchAllDailyKline 按年度窗口分批拉取指定标的的全量日K线。
// fromYear 为起始年份(如烽火转债 2020),到当前年份为止,保证单次请求数据量可控。
func (s *MarketService) FetchAllDailyKline(ctx context.Context, code string, fromYear int) ([]model.Kline, error) {
curYear := time.Now().Year()
var all []model.Kline
for year := fromYear; year <= curYear; year++ {
beg := fmt.Sprintf("%04d0101", year)
end := fmt.Sprintf("%04d1231", year)
ks, err := s.ds.FetchKline(ctx, code, "101", beg, end)
if err != nil {
return nil, fmt.Errorf("fetch kline %d: %w", year, err)
}
all = append(all, ks...)
}
return all, nil
}
// FetchFullKline 单次请求拉取指定标的自 fromYear 起的全量日K线。
// 适合批量入库场景:每只转债仅一次请求,大幅降低全市场抓取时的请求总量。
// 可转债存续期最长约 6 年(约 1500 根),单次请求即可返回完整历史。
func (s *MarketService) FetchFullKline(ctx context.Context, code string, fromYear int) ([]model.Kline, error) {
beg := fmt.Sprintf("%04d0101", fromYear)
return s.ds.FetchKline(ctx, code, "101", beg, "20500101")
}
// FetchIncrementalKline 增量拉取日K线:从 begDate 起到当前日期。
// begDate 格式为 YYYYMMDD(如 "20260801"),对应数据源 API 入参;end 取极大值表示拉取至今。
// 用于增量入库:配合 LastKlineDate 使用,避免重复拉取已存档数据。
func (s *MarketService) FetchIncrementalKline(ctx context.Context, code, begDate string) ([]model.Kline, error) {
return s.ds.FetchKline(ctx, code, "101", begDate, "20500101")
}