topfans/backend/services/userService/main.go
zerosaturation 8a767fb400 fix(backend): service stability — batch 3 accumulated (bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential / ErrTooManyLoginAttempts;
  user-not-found 跑 dummy bcrypt 抹平 ~100ms 时序差; mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级)。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, noop EventProducer.Publish; Init 不再装配 streams;
  11 处硬编码 'gallery'/'default' 抽常量到 pkg/queue/consts (值不变, 消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value, 50-goroutine race_test 零告警;
  MustInit 调用点 gateway main + auth_provider + loadgen 同步更新。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误 记 WARN 不静默;
  Dify err 映射稳定用户文案。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop worker。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go);
  铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。
- .env.example JWT_SECRET 改为 ≥32 字节 base64 示例(原为空, 被 MustInit 立即拒)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 14:03:21 +08:00

308 lines
9.4 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 main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"strconv"
"syscall"
dubboclient "dubbo.apache.org/dubbo-go/v3/client"
_ "dubbo.apache.org/dubbo-go/v3/imports"
"dubbo.apache.org/dubbo-go/v3/protocol"
"dubbo.apache.org/dubbo-go/v3/server"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/health"
"github.com/topfans/backend/pkg/jwt"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
"github.com/topfans/backend/pkg/mq"
asynqAdapter "github.com/topfans/backend/pkg/mq/asynq"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/pkg/statistic"
usermq "github.com/topfans/backend/services/userService/mq"
"github.com/topfans/backend/services/userService/provider"
"github.com/topfans/backend/services/userService/repository"
"github.com/topfans/backend/services/userService/service"
)
var (
port = flag.Int("port", getEnvInt("PORT", 20000), "Dubbo service port")
dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "Database host")
dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "Database port")
dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "Database user")
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
redisHost = flag.String("redis-host", getEnv("REDIS_HOST", "localhost"), "Redis host")
redisPort = flag.Int("redis-port", getEnvInt("REDIS_PORT", 6379), "Redis port")
redisDB = flag.Int("redis-db", getEnvInt("REDIS_DB", 0), "Redis db")
redisPassword = flag.String("redis-password", getEnv("REDIS_PASSWORD", ""), "Redis password")
mqRedisAddr = flag.String("mq-redis-addr", getEnv("MQ_REDIS_ADDR", "localhost:6379"), "MQ redis address")
mqRedisDB = flag.Int("mq-redis-db", getEnvInt("MQ_REDIS_DB", 2), "MQ redis db (avoid clashing with app cache db)")
mqRedisPassword = flag.String("mq-redis-password", getEnv("MQ_REDIS_PASSWORD", ""), "MQ redis password")
mqConcurrency = flag.Int("mq-concurrency", getEnvInt("MQ_CONCURRENCY", 10), "Asynq worker concurrency")
healthHandler *health.Handler
)
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func main() {
flag.Parse()
// 初始化日志(必须在最前面)
env := os.Getenv("ENV")
if env == "" {
env = "development"
}
if err := logger.Init(logger.Config{
ServiceName: "user-service",
Environment: env,
LogLevel: os.Getenv("LOG_LEVEL"),
}); err != nil {
panic(fmt.Sprintf("Failed to initialize logger: %v", err))
}
defer logger.Sync()
logger.Sugar.Info("Starting User Service...")
// 初始化 JWT secret (必须在任何 JWT 操作前)
// fail-fast:空 / 短 / 默认值 三种弱 key 任一命中即 Fatal,绝不静默降级。
jwtSecret := os.Getenv("JWT_SECRET")
if err := jwt.MustInit(jwtSecret); err != nil {
logger.Sugar.Fatalf("FATAL: JWT secret init failed: %v", err)
}
logger.Sugar.Infof("JWT secret loaded (%d bytes)", len(jwtSecret))
// 初始化数据库
if err := initDatabase(); err != nil {
logger.Sugar.Fatalf("Failed to initialize database: %v", err)
}
// 初始化 Redis
if err := initRedis(); err != nil {
logger.Sugar.Fatalf("Failed to initialize Redis: %v", err)
}
// 初始化 SMS 客户端
if err := service.InitSMSClient(); err != nil {
logger.Sugar.Fatalf("Failed to initialize SMS client: %v", err)
}
// 自动迁移数据库表
if err := autoMigrate(); err != nil {
logger.Sugar.Fatalf("Failed to migrate database: %v", err)
}
// 初始化 Dubbo-go 服务器
if err := initDubboService(); err != nil {
logger.Sugar.Fatalf("Failed to initialize Dubbo service: %v", err)
}
// 初始化 MQ (非致命 — MQ 不可用时服务仍正常运行现有 RPC 端点)
if err := mq.Init(mq.Config{
MQDriver: mq.DriverRedis,
Asynq: asynqAdapter.Config{
RedisAddr: *mqRedisAddr,
RedisDB: *mqRedisDB,
Password: *redisPassword,
Concurrency: *mqConcurrency,
Queues: map[string]int{"default": 1},
},
Streams: mq.StreamsConfig{
RedisAddr: *mqRedisAddr,
RedisDB: *mqRedisDB,
Password: *redisPassword,
ConsumerGroup: "topfans-service",
MaxLen: 100000,
ReadBlockMS: 1000,
},
}); err != nil {
logger.Sugar.Warnf("MQ init failed (async tasks disabled, RPC endpoints unaffected): %v", err)
} else {
defer func() { _ = mq.Close() }()
// 注册 MQ handler 并启动 consumer
if err := usermq.RegisterHandlers(initFanProfileRepo()); err != nil {
logger.Sugar.Warnf("Failed to register user MQ handlers: %v", err)
} else {
mqCtx, mqCancel := context.WithCancel(context.Background())
defer mqCancel()
go func() {
if err := usermq.StartConsumers(mqCtx); err != nil {
logger.Sugar.Warnf("user MQ consumer stopped: %v", err)
}
}()
}
}
// 等待信号(优雅关闭)
logger.Sugar.Info("Dubbo-go service started successfully. Press Ctrl+C to exit.")
gracefulShutdown()
}
// initDatabase 初始化数据库连接
func initDatabase() error {
config := database.Config{
Host: *dbHost,
Port: *dbPort,
User: *dbUser,
Password: *dbPassword,
DBName: *dbName,
SSLMode: "disable",
TimeZone: "Asia/Shanghai",
}
return database.Init(config)
}
// initRedis 初始化 Redis 连接
func initRedis() error {
redisConfig := database.RedisConfig{
Host: *redisHost,
Port: *redisPort,
Password: *redisPassword,
DB: *redisDB,
}
return database.InitRedis(redisConfig)
}
// autoMigrate 自动迁移数据库表
func autoMigrate() error {
db := database.GetDB()
if db == nil {
return fmt.Errorf("database is not initialized")
}
// 按顺序迁移(考虑外键依赖)
tables := []interface{}{
&models.Star{},
&models.User{},
&models.FanProfile{},
}
for _, table := range tables {
if err := db.AutoMigrate(table); err != nil {
return fmt.Errorf("failed to migrate table: %w", err)
}
}
logger.Sugar.Info("Database migration completed successfully")
return nil
}
// gracefulShutdown 优雅关闭
func gracefulShutdown() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Sugar.Info("Shutting down server...")
// 关闭健康检查服务器
if healthHandler != nil {
healthHandler.Stop()
}
// 关闭数据库连接
if err := database.Close(); err != nil {
logger.Sugar.Errorf("Error closing database: %v", err)
}
logger.Sugar.Info("Server exited")
}
// initFanProfileRepo 创建 fan profile repository 实例(供 mq handler 使用)。
// initFanProfileRepo 创建 fan profile repository 实例(供 mq handler 使用)。
func initFanProfileRepo() repository.FanProfileRepository {
return repository.NewFanProfileRepository()
}
// initDubboService 初始化Dubbo-go服务
func initDubboService() error {
// 启动健康检查 HTTP 服务器
healthPort := *port + 1000 // e.g., 20000 -> 21000
healthHandler = health.NewHandler("user-service", healthPort)
healthHandler.Start()
db := database.GetDB()
if db == nil {
return fmt.Errorf("database is not initialized")
}
// 创建Repository实例
userRepo := repository.NewUserRepository()
fanProfileRepo := repository.NewFanProfileRepository()
starRepo := repository.NewStarRepository()
// 创建Service实例
authService := service.NewAuthService(userRepo, fanProfileRepo, starRepo, db)
userService := service.NewUserService(userRepo, fanProfileRepo, db)
identityService := service.NewIdentityService(fanProfileRepo, starRepo, userRepo, db)
// 初始化 statisticService SDK事件埋点fire-and-forget
statisticServiceURL := getEnv("STATISTIC_SERVICE_URL", "tri://localhost:20009")
statisticCli, err := dubboclient.NewClient(dubboclient.WithClientURL(statisticServiceURL))
if err != nil {
logger.Sugar.Warnf("statisticService client create failed (events disabled): %v", err)
} else if err := statistic.Init(statisticCli); err != nil {
logger.Sugar.Warnf("statistic.Init failed (events disabled): %v", err)
} else {
logger.Sugar.Info("Statistic SDK initialized")
}
// 创建Provider实例
authProvider := provider.NewAuthProvider(authService)
userProvider := provider.NewUserProvider(userService, identityService)
// 创建统一Provider实例作为集成层委托给其他Provider
unifiedProvider := provider.NewUnifiedProvider(authProvider, userProvider)
// 使用编程式注册方式(参考 dubbo-go-samples/helloworld
// 创建 Dubbo Server
srv, err := server.NewServer(
server.WithServerProtocol(
protocol.WithPort(*port),
protocol.WithTriple(),
),
)
if err != nil {
return fmt.Errorf("failed to create Dubbo server: %w", err)
}
// 使用 Triple 协议生成的 RegisterHandler 函数注册服务
if err := pb.RegisterUserSocialServiceHandler(srv, unifiedProvider); err != nil {
return fmt.Errorf("failed to register UserSocialService handler: %w", err)
}
logger.Sugar.Info("Dubbo-go unified provider registered successfully",
"service", "topfans.user.UserSocialService",
"port", *port,
)
// 在后台启动 Dubbo 服务器
go func() {
if err := srv.Serve(); err != nil {
logger.Sugar.Fatalf("Failed to serve Dubbo: %v", err)
}
}()
return nil
}