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
|
||||
}
|
||||
153
internal/service/auth_test.go
Normal file
153
internal/service/auth_test.go
Normal 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{}
|
||||
}
|
||||
76
internal/service/market.go
Normal file
76
internal/service/market.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user