diff --git a/README.md b/README.md index a7c34e2..a4cda0d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ ### 1.6 通知与实时推送 -- WebSocket 网关位于 `/ws`,使用 JWT 鉴权 +- WebSocket 网关位于 `/ws`,使用一次性 ticket 鉴权 - 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery` - Web/APP/小程序均接入 WebSocket 实时订阅 - 登录后自动连接 WebSocket,登出自动断开 @@ -244,7 +244,7 @@ silk/ | Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 | | AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 | | Control | `handler/control.go` | MQTT 控制命令下发 | -| Notification | `handler/notification.go` | Redis 站内通知 | +| Notification | `handler/notification.go` | PostgreSQL 持久化通知与 Outbox 状态 | | Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 | | Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 | | Video Record | `handler/video_record.go` | 录制启停、活跃录制查询 | @@ -442,9 +442,9 @@ IOTDB_URL=http://localhost:18081 IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature` -### 10.3 Valkey/Redis(缓存与通知) +### 10.3 Valkey/Redis(跨实例认证状态) -用于权限缓存(5 分钟有效期)、站内通知列表。 +用于 JWT 吊销、登录限流等跨实例状态;通知与事件箱持久化在 PostgreSQL,Redis 不可用时登录/刷新/吊销和已认证接口保守失败,不回退单机内存。 ### 10.4 Ceph S3(录像存储) @@ -502,7 +502,7 @@ C:\msys64\usr\bin\sshpass.exe -p "pan" C:\msys64\usr\bin\ssh.exe -o StrictHostKe | `APP_ENV` | `development` | 运行环境;生产环境应设为 `production` | | `ALLOW_DEV_AUTOMIGRATE` | `false` | 仅开发环境可显式开启 AutoMigrate,生产忽略此开关 | | `WS_ALLOWED_ORIGINS` | `http://localhost:5174,http://localhost:3000,...` | WebSocket 允许的 Origin,逗号分隔 | -| `REDIS` | `redis://:pan@localhost:6379` | Valkey/Redis 连接串 | +| `REDIS` | `redis://:pan@localhost:6379` | Valkey/Redis 连接串;不可用时认证/限流接口返回 503 | | `JWT_SECRET` | `silk-secret-please-change-me` | JWT 签名密钥 | | `JWT_EXPIRES_IN` | `2h` | JWT 有效期 | | `MQTT` | `mqtt://pan:pan@localhost:1883` | MQTT 连接串 | diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 04a13d6..53f49f5 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -42,14 +42,24 @@ func main() { slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err) } - // 4. 连接 Redis(失败不阻断启动) + // 4. 连接 Redis(失败不阻断启动,但认证/限流保守失败) + var rdb *redis.Client if opt, err := redis.ParseURL(cfg.Redis); err == nil { - rdb := redis.NewClient(opt) + rdb = redis.NewClient(opt) if err := rdb.Ping(context.Background()).Err(); err != nil { - slog.Warn("Redis 连接失败", "err", err) + slog.Error("Redis 连接失败,认证/限流将保守失败", "err", err) + _ = rdb.Close() + rdb = nil } else { slog.Info("Redis 连接成功") } + } else { + slog.Error("Redis URL 解析失败,认证/限流将保守失败", "err", err) + } + if rdb != nil { + middleware.InitState(middleware.NewRedisState(rdb), cfg.AppEnv) + } else { + middleware.InitState(nil, cfg.AppEnv) } // 5. 创建 WebSocket Hub @@ -76,6 +86,11 @@ func main() { aiSvc := service.NewAIClient(cfg.AIServiceBase) wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret) weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation) + outbox := service.NewOutbox(db) + outbox.SetHandler(service.NewWechatOutboxHandler(db, wechatSvc)) + outboxCtx, cancelOutbox := context.WithCancel(context.Background()) + defer cancelOutbox() + outbox.Start(outboxCtx) // 9. 创建 Gin 引擎 gin.SetMode(gin.ReleaseMode) @@ -111,7 +126,7 @@ func main() { handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg) handler.RegisterStorageRoutes(api, db) handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages) - handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection, cfg.AppEnv) + handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, outbox, cfg.WechatTemplateInspection, cfg.AppEnv) handler.RegisterTrayBatchRoutes(api, db) handler.RegisterWechatRoutes(api, db, wechatSvc) handler.RegisterWeatherRoutes(api, db, weatherSvc) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index 43ed346..bdea348 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -36,6 +36,8 @@ func Init(cfg *config.Config) error { &model.Permission{}, &model.RolePermission{}, &model.Disease{}, &model.KnowledgeArticle{}, &model.InspectionRecord{}, + &model.OutboxEvent{}, + &model.Notification{}, &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, &model.WeatherAlert{}, diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index 529eff2..23798b2 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "2" +const CurrentSchemaVersion = "4" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index b3b6720..186f010 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -104,4 +104,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if err != nil || next != 2 { t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err) } + next, err = driver.Next(next) + if err != nil || next != 4 { + t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err) + } } diff --git a/server-go/internal/handler/auth.go b/server-go/internal/handler/auth.go index bcb827d..1d6157c 100644 --- a/server-go/internal/handler/auth.go +++ b/server-go/internal/handler/auth.go @@ -72,7 +72,7 @@ func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { } // 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色) - role := model.RoleViewer + role := model.RoleViewer user := model.User{ Username: body.Username, Email: body.Email, @@ -123,13 +123,19 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { var user model.User if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil { - middleware.RecordLoginFail(c, body.Username) + if err := middleware.RecordLoginFail(c, body.Username); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil { - middleware.RecordLoginFail(c, body.Username) + if err := middleware.RecordLoginFail(c, body.Username); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } @@ -140,7 +146,10 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { } // 登录成功,清空失败计数 - middleware.RecordLoginSuccess(c, body.Username) + if err := middleware.RecordLoginSuccess(c, body.Username); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } // 记录审计日志 uid := user.ID @@ -184,7 +193,12 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"}) return } - if middleware.IsRevoked(claims) { + revoked, err := middleware.IsRevoked(claims) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "认证状态服务不可用"}) + return + } + if revoked { c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"}) return } @@ -201,7 +215,10 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { } // 吊销旧刷新令牌(一次性使用),签发新令牌对 - middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time) + if err := middleware.RevokeToken(claims, body.RefreshToken, claims.ExpiresAt.Time); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, buildLoginPayload(db, user, cfg)) } } @@ -214,7 +231,10 @@ func logoutHandler(cfg *config.Config) gin.HandlerFunc { if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid { if claims.ExpiresAt != nil { - middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time) + if err := middleware.RevokeToken(claims, parts[1], claims.ExpiresAt.Time); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } } } } diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index 5c5c04f..99d34e0 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -2,8 +2,8 @@ package handler import ( "bytes" - "context" "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -27,8 +27,8 @@ func isUUID(s string) bool { } // RegisterInspectionRoutes 注册 AI 巡检路由 -func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) { - rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID, appEnv)) +func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) { + rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, outbox, inspectionTemplateID, appEnv)) rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db)) } @@ -66,7 +66,7 @@ func buildRiskInput(detRes *service.AIDetectResponse) service.RiskInput { // createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。 // 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。 -func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) gin.HandlerFunc { +func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, outbox *service.Outbox, inspectionTemplateID string, appEnv string) gin.HandlerFunc { return func(c *gin.Context) { idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) roomID := strings.TrimSpace(c.PostForm("roomId")) @@ -155,38 +155,40 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, rawRisk, _ := json.Marshal(assessment) rec.RiskAssessment = rawRisk - // 微信订阅消息(#11 骨架):Mock 结果不进入告警,风险非绿且用户已授权时异步推送 - if !isMock { - if key := service.WechatTemplateKey(assessment.Level); key != "" { - go func(uid *string, lv string, sc float64) { - if uid == nil || !wechat.Configured() || inspectionTemplateID == "" { - return - } - var binding model.WechatBinding - if db.Where("user_id = ?", *uid).First(&binding).Error != nil { - return - } - var authorized []string - if len(binding.AuthorizedTemplates) > 0 { - _ = json.Unmarshal(binding.AuthorizedTemplates, &authorized) - } - if !service.IsAuthorized(authorized, key) { - return - } - _ = wechat.SendSubscribe( - context.Background(), - binding.OpenID, - inspectionTemplateID, - service.BuildSubscribeData(lv, sc), - "pages/inspection/index", - ) - }(rec.UserID, assessment.Level, assessment.Score) - } - } } } - if err := db.Create(&rec).Error; err != nil { + // 微信订阅消息(#11 骨架):Mock 不进入告警;业务事务内写 outbox,重启后仍可重试 + txErr := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&rec).Error; err != nil { + return err + } + if rec.AIStatus == "done" && rec.UserID != nil && rec.IsMock != nil && !*rec.IsMock { + if key := service.WechatTemplateKey(*rec.RiskLevel); key != "" { + payload, _ := json.Marshal(service.WechatSubscribePayload{ + UserID: *rec.UserID, + TemplateKey: key, + TemplateID: inspectionTemplateID, + Page: "pages/inspection/index", + Title: "巡检风险提醒", + Body: fmt.Sprintf("风险等级 %s,风险分 %.0f", *rec.RiskLevel, *rec.RiskScore), + Data: service.BuildSubscribeData(*rec.RiskLevel, *rec.RiskScore), + BusinessType: "inspection", + BusinessID: rec.ID, + }) + event := service.Event{ + ID: "inspection-" + rec.ID + "-" + *rec.RiskLevel, + Type: service.OutboxEventWechatSubscribe, + AggregateType: "inspection", + AggregateID: rec.ID, + Payload: payload, + } + return outbox.PublishTx(tx, event) + } + } + return nil + }) + if txErr != nil { // 并发幂等:唯一索引冲突时返回已有记录 if idemKey != "" { var exist model.InspectionRecord diff --git a/server-go/internal/handler/notification.go b/server-go/internal/handler/notification.go index 0309999..dd8417b 100644 --- a/server-go/internal/handler/notification.go +++ b/server-go/internal/handler/notification.go @@ -1,61 +1,34 @@ package handler import ( - "fmt" - "math/rand" "net/http" - "sync" "time" "silk-server-go/internal/middleware" + "silk-server-go/internal/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) -// notificationItem 通知项 -type notificationItem struct { - ID string `json:"id"` - Channel string `json:"channel"` - Target string `json:"target"` - Title string `json:"title"` - Body string `json:"body"` - CreatedAt string `json:"createdAt"` -} - -// 通知内存存储(后续可替换为 Redis) -var ( - notificationStore []notificationItem - notificationMu sync.Mutex -) - // RegisterNotificationRoutes 注册通知路由 func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) { readPerm := middleware.RequirePermission(db, "alarm:read") - rg.GET("/notifications", readPerm, listNotifications()) - rg.POST("/notifications", readPerm, createNotification()) + rg.GET("/notifications", readPerm, listNotifications(db)) + rg.POST("/notifications", readPerm, createNotification(db)) } // listNotifications 通知列表(上限200) -func listNotifications() gin.HandlerFunc { +func listNotifications(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - notificationMu.Lock() - defer notificationMu.Unlock() - - limit := 200 - if len(notificationStore) < limit { - limit = len(notificationStore) - } - // 返回最新的 limit 条(存储已按新到旧排序) - result := make([]notificationItem, limit) - copy(result, notificationStore[:limit]) - - c.JSON(http.StatusOK, result) + var list []model.Notification + db.Order("created_at DESC").Limit(200).Find(&list) + c.JSON(http.StatusOK, list) } } // createNotification 手动发通知 -func createNotification() gin.HandlerFunc { +func createNotification(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var body struct { Channel string `json:"channel"` @@ -68,24 +41,25 @@ func createNotification() gin.HandlerFunc { return } - ntf := notificationItem{ - ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)), - Channel: body.Channel, - Target: body.Target, - Title: body.Title, - Body: body.Body, - CreatedAt: time.Now().Format(time.RFC3339), + ntf := model.Notification{ + UserID: currentUserID(c), + Channel: body.Channel, + Target: body.Target, + Title: body.Title, + Body: body.Body, + Status: "sent", + NextAttemptAt: time.Now(), } - - notificationMu.Lock() - // 插入到头部(最新在前) - notificationStore = append([]notificationItem{ntf}, notificationStore...) - // 保留最近 500 条 - if len(notificationStore) > 500 { - notificationStore = notificationStore[:500] + if ntf.Channel == "" { + ntf.Channel = "manual" + } + if ntf.Target == "" { + ntf.Target = "all" + } + if err := db.Create(&ntf).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建通知失败"}) + return } - notificationMu.Unlock() - c.JSON(http.StatusCreated, ntf) } } diff --git a/server-go/internal/middleware/auth.go b/server-go/internal/middleware/auth.go index 2828cec..982da00 100644 --- a/server-go/internal/middleware/auth.go +++ b/server-go/internal/middleware/auth.go @@ -20,16 +20,16 @@ type JWTClaims struct { // 白名单路径,无需鉴权 var whitelist = map[string]bool{ - "/api/v1/health": true, - "/health": true, - "/api/health": true, - "/api/v1/auth/login": true, - "/auth/login": true, - "/api/v1/auth/register": true, - "/auth/register": true, - "/api/v1/auth/refresh": true, - "/auth/refresh": true, - "/api/v1/video/clips/internal": true, + "/api/v1/health": true, + "/health": true, + "/api/health": true, + "/api/v1/auth/login": true, + "/auth/login": true, + "/api/v1/auth/register": true, + "/auth/register": true, + "/api/v1/auth/refresh": true, + "/auth/refresh": true, + "/api/v1/video/clips/internal": true, "/api/v1/video/recordings/internal/end": true, } @@ -83,7 +83,12 @@ func Auth(cfg *config.Config) gin.HandlerFunc { } // 校验令牌是否已被登出吊销 - if IsRevoked(claims) { + revoked, err := IsRevoked(claims) + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "认证状态服务不可用"}) + return + } + if revoked { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"}) return } diff --git a/server-go/internal/middleware/ratelimit.go b/server-go/internal/middleware/ratelimit.go index 97b8475..d5b5ab0 100644 --- a/server-go/internal/middleware/ratelimit.go +++ b/server-go/internal/middleware/ratelimit.go @@ -1,110 +1,41 @@ package middleware import ( + "context" "net/http" "strings" - "sync" "time" "github.com/gin-gonic/gin" ) -// loginAttempt 登录失败计数(按 IP + 用户名维度) -type loginAttempt struct { - failures int - lockUntil time.Time - lastFail time.Time -} - -type loginLimiter struct { - mu sync.Mutex - seen map[string]*loginAttempt -} - const ( - maxFailures = 5 // 连续失败 5 次后锁定 - lockDuration = 15 * time.Minute - failureWindow = 10 * time.Minute // 失败计数窗口 - cleanupInterval = 5 * time.Minute + maxFailures = 5 // 连续失败 5 次后锁定 + lockDuration = 15 * time.Minute + failureWindow = 10 * time.Minute // 失败计数窗口 ) -var defaultLoginLimiter = newLoginLimiter() - -func newLoginLimiter() *loginLimiter { - l := &loginLimiter{seen: make(map[string]*loginAttempt)} - go l.cleanupLoop() - return l -} - -func (l *loginLimiter) cleanupLoop() { - t := time.NewTicker(cleanupInterval) - defer t.Stop() - for range t.C { - l.mu.Lock() - now := time.Now() - for k, v := range l.seen { - if now.After(v.lockUntil) && now.Sub(v.lastFail) > failureWindow { - delete(l.seen, k) - } - } - l.mu.Unlock() - } -} - // key = ip + "|" + username(小写) func limiterKey(c *gin.Context, username string) string { return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username)) } -// checkLock 返回是否被锁定及剩余锁定时间 -func (l *loginLimiter) checkLock(key string) (bool, time.Duration) { - l.mu.Lock() - defer l.mu.Unlock() - a, ok := l.seen[key] - if !ok { - return false, 0 - } - if time.Now().Before(a.lockUntil) { - return true, time.Until(a.lockUntil) - } - return false, 0 -} - -// recordFailure 记录一次失败,达到阈值则锁定 -func (l *loginLimiter) recordFailure(key string) { - l.mu.Lock() - defer l.mu.Unlock() - a, ok := l.seen[key] - if !ok { - a = &loginAttempt{} - l.seen[key] = a - } - now := time.Now() - // 窗口外重置 - if now.Sub(a.lastFail) > failureWindow { - a.failures = 0 - } - a.failures++ - a.lastFail = now - if a.failures >= maxFailures { - a.lockUntil = now.Add(lockDuration) - } -} - -// recordSuccess 登录成功后清空计数 -func (l *loginLimiter) recordSuccess(key string) { - l.mu.Lock() - delete(l.seen, key) - l.mu.Unlock() -} - // CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用) func CheckLoginLock(c *gin.Context, username string) bool { + if authState == nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": stateUnavailable("执行登录限流").Error()}) + return true + } key := limiterKey(c, username) - if locked, remain := defaultLoginLimiter.checkLock(key); locked { + locked, remain, err := authState.CheckLoginLock(context.Background(), key) + if err != nil { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "登录状态服务不可用"}) + return true + } + if locked { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ - "error": "登录尝试过多,已锁定,请稍后再试", - "retry": int(remain.Minutes()) + 1, + "error": "登录尝试过多,已锁定,请稍后再试", + "retry": int(remain.Minutes()) + 1, }) return true } @@ -112,11 +43,17 @@ func CheckLoginLock(c *gin.Context, username string) bool { } // RecordLoginFail 记录登录失败 -func RecordLoginFail(c *gin.Context, username string) { - defaultLoginLimiter.recordFailure(limiterKey(c, username)) +func RecordLoginFail(c *gin.Context, username string) error { + if authState == nil { + return stateUnavailable("记录登录失败") + } + return authState.RecordLoginFailure(context.Background(), limiterKey(c, username)) } // RecordLoginSuccess 登录成功后清空计数 -func RecordLoginSuccess(c *gin.Context, username string) { - defaultLoginLimiter.recordSuccess(limiterKey(c, username)) +func RecordLoginSuccess(c *gin.Context, username string) error { + if authState == nil { + return stateUnavailable("清空登录失败计数") + } + return authState.RecordLoginSuccess(context.Background(), limiterKey(c, username)) } diff --git a/server-go/internal/middleware/state.go b/server-go/internal/middleware/state.go new file mode 100644 index 0000000..ca4203d --- /dev/null +++ b/server-go/internal/middleware/state.go @@ -0,0 +1,104 @@ +package middleware + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/redis/go-redis/v9" +) + +const statePrefix = "silk:auth:" + +// StateStore 跨实例认证状态存储。 +type StateStore interface { + RevokeToken(ctx context.Context, id string, exp time.Time) error + IsTokenRevoked(ctx context.Context, id string) (bool, error) + CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error) + RecordLoginFailure(ctx context.Context, key string) error + RecordLoginSuccess(ctx context.Context, key string) error +} + +var ( + authState StateStore + appEnv string +) + +// InitState 设置认证状态存储;store 为 nil 时认证相关接口保守失败。 +func InitState(store StateStore, env string) { + authState = store + appEnv = env +} + +// RedisState Redis 实现。 +type RedisState struct { + rdb *redis.Client + prefix string +} + +// NewRedisState 创建 Redis 状态存储。 +func NewRedisState(rdb *redis.Client) *RedisState { + return &RedisState{rdb: rdb, prefix: statePrefix} +} + +func (s *RedisState) RevokeToken(ctx context.Context, id string, exp time.Time) error { + ttl := time.Until(exp) + if ttl <= 0 { + return nil + } + return s.rdb.Set(ctx, s.prefix+"revoked:"+id, "1", ttl).Err() +} + +func (s *RedisState) IsTokenRevoked(ctx context.Context, id string) (bool, error) { + count, err := s.rdb.Exists(ctx, s.prefix+"revoked:"+id).Result() + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *RedisState) CheckLoginLock(ctx context.Context, key string) (bool, time.Duration, error) { + lockKey := s.prefix + "login-lock:" + key + if _, err := s.rdb.Get(ctx, lockKey).Result(); err == redis.Nil { + return false, 0, nil + } else if err != nil { + return false, 0, err + } + ttl, err := s.rdb.TTL(ctx, lockKey).Result() + if err != nil { + return false, 0, err + } + return true, ttl, nil +} + +var loginFailureScript = redis.NewScript(` +local count = redis.call('INCR', KEYS[1]) +redis.call('EXPIRE', KEYS[1], ARGV[1]) +if tonumber(count) >= tonumber(ARGV[2]) then + redis.call('SET', KEYS[2], '1', 'PX', ARGV[3]) +end +return count +`) + +func (s *RedisState) RecordLoginFailure(ctx context.Context, key string) error { + return loginFailureScript.Run(ctx, s.rdb, + []string{s.prefix + "login-failures:" + key, s.prefix + "login-lock:" + key}, + int(failureWindow.Seconds()), maxFailures, int(lockDuration.Milliseconds()), + ).Err() +} + +func (s *RedisState) RecordLoginSuccess(ctx context.Context, key string) error { + pipe := s.rdb.Pipeline() + pipe.Del(ctx, s.prefix+"login-failures:"+key, s.prefix+"login-lock:"+key) + _, err := pipe.Exec(ctx) + return err +} + +func stateUnavailable(operation string) error { + msg := "Redis 状态服务不可用,无法" + operation + if appEnv == "production" { + slog.Error(msg) + } + return fmt.Errorf("%s", msg) +} diff --git a/server-go/internal/middleware/state_test.go b/server-go/internal/middleware/state_test.go new file mode 100644 index 0000000..92804da --- /dev/null +++ b/server-go/internal/middleware/state_test.go @@ -0,0 +1,152 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +type memoryState struct { + mu sync.Mutex + revoked map[string]time.Time + failures map[string]int + locked map[string]time.Time +} + +func newMemoryState() *memoryState { + return &memoryState{ + revoked: map[string]time.Time{}, + failures: map[string]int{}, + locked: map[string]time.Time{}, + } +} + +func (m *memoryState) RevokeToken(_ context.Context, id string, exp time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + m.revoked[id] = exp + return nil +} + +func (m *memoryState) IsTokenRevoked(_ context.Context, id string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + exp, ok := m.revoked[id] + if !ok { + return false, nil + } + if time.Now().After(exp) { + delete(m.revoked, id) + return false, nil + } + return true, nil +} + +func (m *memoryState) CheckLoginLock(_ context.Context, key string) (bool, time.Duration, error) { + m.mu.Lock() + defer m.mu.Unlock() + until, ok := m.locked[key] + if !ok { + return false, 0, nil + } + if time.Now().After(until) { + delete(m.locked, key) + return false, 0, nil + } + return true, time.Until(until), nil +} + +func (m *memoryState) RecordLoginFailure(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.failures[key]++ + if m.failures[key] >= maxFailures { + m.locked[key] = time.Now().Add(lockDuration) + } + return nil +} + +func (m *memoryState) RecordLoginSuccess(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.failures, key) + delete(m.locked, key) + return nil +} + +func withMemoryState(t *testing.T) *memoryState { + t.Helper() + store := newMemoryState() + InitState(store, "test") + t.Cleanup(func() { InitState(nil, "test") }) + return store +} + +func TestRevokeTokenIsCrossInstanceState(t *testing.T) { + withMemoryState(t) + claims := &JWTClaims{} + claims.ID = "token-1" + if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("RevokeToken failed: %v", err) + } + revoked, err := IsRevoked(claims) + if err != nil { + t.Fatalf("IsRevoked failed: %v", err) + } + if !revoked { + t.Fatal("token should be revoked") + } +} + +func TestRevokeTokenUnavailableFailsClosed(t *testing.T) { + InitState(nil, "production") + t.Cleanup(func() { InitState(nil, "test") }) + claims := &JWTClaims{} + claims.ID = "x" + if err := RevokeToken(claims, "token", time.Now().Add(time.Hour)); err == nil { + t.Fatal("state unavailable should fail closed") + } +} + +func TestLoginLockStateSemantics(t *testing.T) { + store := withMemoryState(t) + for i := 0; i < maxFailures; i++ { + if err := store.RecordLoginFailure(context.Background(), "1.1.1.1|user"); err != nil { + t.Fatalf("record failure failed: %v", err) + } + } + locked, _, err := store.CheckLoginLock(context.Background(), "1.1.1.1|user") + if err != nil { + t.Fatalf("check lock failed: %v", err) + } + if !locked { + t.Fatal("expected login lock after max failures") + } + if err := store.RecordLoginSuccess(context.Background(), "1.1.1.1|user"); err != nil { + t.Fatalf("record success failed: %v", err) + } + locked, _, _ = store.CheckLoginLock(context.Background(), "1.1.1.1|user") + if locked { + t.Fatal("login lock should be cleared after success") + } +} + +func TestCheckLoginLockUnavailableReturns503(t *testing.T) { + InitState(nil, "test") + t.Cleanup(func() { InitState(nil, "test") }) + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil) + if !CheckLoginLock(c, "user") { + t.Fatal("state unavailable should abort login") + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/server-go/internal/middleware/token_blacklist.go b/server-go/internal/middleware/token_blacklist.go index 4298ca5..f20ca4b 100644 --- a/server-go/internal/middleware/token_blacklist.go +++ b/server-go/internal/middleware/token_blacklist.go @@ -1,45 +1,26 @@ package middleware import ( - "sync" + "context" "time" "github.com/golang-jwt/jwt/v5" ) -// tokenBlacklist 登出令牌黑名单(内存版,进程重启后失效,令牌自然过期兜底) -type tokenBlacklist struct { - mu sync.RWMutex - revoked map[string]time.Time // tokenID(jti) -> 过期时间 -} - -var defaultBlacklist = &tokenBlacklist{revoked: make(map[string]time.Time)} - // RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间) -func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) { - id := tokenIdentifier(claims) - defaultBlacklist.mu.Lock() - defaultBlacklist.revoked[id] = exp - defaultBlacklist.mu.Unlock() +func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) error { + if authState == nil { + return stateUnavailable("吊销令牌") + } + return authState.RevokeToken(context.Background(), tokenIdentifier(claims), exp) } // IsRevoked 判断令牌是否已被吊销 -func IsRevoked(claims *JWTClaims) bool { - id := tokenIdentifier(claims) - defaultBlacklist.mu.RLock() - exp, ok := defaultBlacklist.revoked[id] - defaultBlacklist.mu.RUnlock() - if !ok { - return false +func IsRevoked(claims *JWTClaims) (bool, error) { + if authState == nil { + return false, stateUnavailable("校验令牌吊销状态") } - // 已过期的黑名单项自动清理 - if time.Now().After(exp) { - defaultBlacklist.mu.Lock() - delete(defaultBlacklist.revoked, id) - defaultBlacklist.mu.Unlock() - return false - } - return true + return authState.IsTokenRevoked(context.Background(), tokenIdentifier(claims)) } // ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用) diff --git a/server-go/internal/model/notification.go b/server-go/internal/model/notification.go new file mode 100644 index 0000000..3f29f4c --- /dev/null +++ b/server-go/internal/model/notification.go @@ -0,0 +1,51 @@ +package model + +import ( + "encoding/json" + "time" +) + +// OutboxEvent 可靠事件箱;业务事务内写入,worker 发送并重试。 +type OutboxEvent struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + EventID string `gorm:"column:event_id;size:128;uniqueIndex" json:"eventId"` + EventType string `gorm:"column:event_type;size:64" json:"eventType"` + AggregateType *string `gorm:"column:aggregate_type;size:64" json:"aggregateType,omitempty"` + AggregateID *string `gorm:"column:aggregate_id;size:128" json:"aggregateId,omitempty"` + Payload json.RawMessage `gorm:"type:jsonb;default:'{}'" json:"payload"` + Status string `gorm:"size:16;default:pending;index:idx_outbox_events_status_next,priority:1" json:"status"` + Attempts int `gorm:"default:0" json:"attempts"` + MaxAttempts int `gorm:"column:max_attempts;default:5" json:"maxAttempts"` + NextAttemptAt time.Time `gorm:"column:next_attempt_at;type:timestamptz;default:now();index:idx_outbox_events_status_next,priority:2" json:"nextAttemptAt"` + LastError *string `gorm:"column:last_error;type:text" json:"lastError,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (OutboxEvent) TableName() string { return "outbox_events" } + +// Notification 持久化通知记录;状态 pending/sending/sent/retry/failed/cancelled。 +type Notification struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID *string `gorm:"column:user_id;type:uuid;index:idx_notifications_user_created,priority:1" json:"userId,omitempty"` + Channel string `gorm:"size:32" json:"channel"` + Target string `gorm:"size:128" json:"target"` + Title string `gorm:"size:128" json:"title"` + Body string `gorm:"type:text" json:"body"` + Status string `gorm:"size:16;default:pending;index:idx_notifications_status_next,priority:1" json:"status"` + Attempts int `gorm:"default:0" json:"attempts"` + NextAttemptAt time.Time `gorm:"column:next_attempt_at;type:timestamptz;default:now();index:idx_notifications_status_next,priority:2" json:"nextAttemptAt"` + LastError *string `gorm:"column:last_error;type:text" json:"lastError,omitempty"` + TemplateID *string `gorm:"column:template_id;size:128" json:"templateId,omitempty"` + OpenID *string `gorm:"column:open_id;size:128" json:"openId,omitempty"` + Authorized bool `gorm:"default:false" json:"authorized"` + ProviderResponse json.RawMessage `gorm:"column:provider_response;type:jsonb" json:"providerResponse,omitempty"` + Data json.RawMessage `gorm:"type:jsonb" json:"data,omitempty"` + BusinessType *string `gorm:"column:business_type;size:64" json:"businessType,omitempty"` + BusinessID *string `gorm:"column:business_id;size:128" json:"businessId,omitempty"` + EventID *string `gorm:"column:event_id;size:128;uniqueIndex" json:"eventId,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (Notification) TableName() string { return "notifications" } diff --git a/server-go/internal/service/outbox.go b/server-go/internal/service/outbox.go new file mode 100644 index 0000000..f70770c --- /dev/null +++ b/server-go/internal/service/outbox.go @@ -0,0 +1,216 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "log/slog" + "time" + + "silk-server-go/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + OutboxStatusPending = "pending" + OutboxStatusSending = "sending" + OutboxStatusSent = "sent" + OutboxStatusRetry = "retry" + OutboxStatusFailed = "failed" + OutboxStatusCancelled = "cancelled" + + OutboxEventWechatSubscribe = "wechat.subscribe" +) + +// Event 写入 outbox 的领域事件。 +type Event struct { + ID string + Type string + AggregateType string + AggregateID string + Payload json.RawMessage +} + +// EventHandler 处理一条已领取的 outbox 事件。 +type EventHandler func(ctx context.Context, event model.OutboxEvent) error + +// Outbox 基于 PostgreSQL 的可靠事件箱。 +type Outbox struct { + db *gorm.DB + handler EventHandler + interval time.Duration + batchSize int + maxAttempts int + backoff time.Duration +} + +// NewOutbox 创建默认配置的 Outbox。 +func NewOutbox(db *gorm.DB) *Outbox { + return &Outbox{ + db: db, + interval: 5 * time.Second, + batchSize: 20, + maxAttempts: 5, + backoff: 15 * time.Second, + } +} + +// SetHandler 设置事件处理器。 +func (o *Outbox) SetHandler(handler EventHandler) { + o.handler = handler +} + +// PublishTx 在业务事务内写入事件;重复 eventId 通过唯一索引幂等跳过。 +func (o *Outbox) PublishTx(tx *gorm.DB, event Event) error { + if event.Type == "" { + return errors.New("outbox event type is required") + } + if event.ID == "" { + event.ID = randomEventID() + } + payload := event.Payload + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + record := model.OutboxEvent{ + EventID: event.ID, + EventType: event.Type, + AggregateType: strPtrOrNil(event.AggregateType), + AggregateID: strPtrOrNil(event.AggregateID), + Payload: payload, + Status: OutboxStatusPending, + MaxAttempts: o.maxAttempts, + NextAttemptAt: time.Now(), + } + // PublishTx 必须复用调用方事务,避免 GORM 对单条 Create 再开嵌套事务。 + return tx.Session(&gorm.Session{SkipDefaultTransaction: true}).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "event_id"}}, + DoNothing: true, + }).Create(&record).Error +} + +// ClaimNext 领取一批到期待处理事件。 +func (o *Outbox) ClaimNext(ctx context.Context, limit int) ([]model.OutboxEvent, error) { + if limit <= 0 { + limit = o.batchSize + } + var events []model.OutboxEvent + err := o.db.WithContext(ctx). + Where("status IN ? AND next_attempt_at <= ?", []string{OutboxStatusPending, OutboxStatusRetry}, time.Now()). + Order("created_at ASC"). + Limit(limit). + Find(&events).Error + return events, err +} + +// MarkSending 原子标记领取状态,避免多 worker 重复处理。 +func (o *Outbox) MarkSending(ctx context.Context, id string) (bool, error) { + result := o.db.WithContext(ctx). + Model(&model.OutboxEvent{}). + Where("id = ? AND status IN ?", id, []string{OutboxStatusPending, OutboxStatusRetry}). + Update("status", OutboxStatusSending) + return result.RowsAffected > 0, result.Error +} + +// MarkResult 写入成功/失败并计算下一次重试时间。 +func (o *Outbox) MarkResult(ctx context.Context, id string, processErr error) error { + var event model.OutboxEvent + if err := o.db.WithContext(ctx).First(&event, "id = ?", id).Error; err != nil { + return err + } + if event.MaxAttempts <= 0 { + event.MaxAttempts = o.maxAttempts + } + event.Attempts++ + now := time.Now() + updates := map[string]interface{}{ + "attempts": event.Attempts, + "updated_at": now, + "next_attempt_at": now, + } + if processErr == nil { + updates["status"] = OutboxStatusSent + updates["last_error"] = nil + } else { + message := processErr.Error() + updates["last_error"] = message + if event.Attempts >= event.MaxAttempts { + updates["status"] = OutboxStatusFailed + } else { + updates["status"] = OutboxStatusRetry + updates["next_attempt_at"] = now.Add(o.backoff * time.Duration(event.Attempts)) + } + } + return o.db.WithContext(ctx). + Model(&model.OutboxEvent{}). + Where("id = ?", id). + Updates(updates).Error +} + +// ProcessPending 领取并处理到期事件,返回本轮处理数量。 +func (o *Outbox) ProcessPending(ctx context.Context) (int, error) { + if o.handler == nil { + return 0, errors.New("outbox handler is not set") + } + processed := 0 + for { + events, err := o.ClaimNext(ctx, o.batchSize) + if err != nil { + return processed, err + } + if len(events) == 0 { + return processed, nil + } + for _, event := range events { + claimed, err := o.MarkSending(ctx, event.ID) + if err != nil { + slog.Warn("outbox mark sending failed", "eventId", event.EventID, "err", err) + continue + } + if !claimed { + continue + } + processErr := o.handler(ctx, event) + if err := o.MarkResult(ctx, event.ID, processErr); err != nil { + slog.Warn("outbox mark result failed", "eventId", event.EventID, "err", err) + continue + } + processed++ + } + } +} + +// Start 启动后台 worker。 +func (o *Outbox) Start(ctx context.Context) { + ticker := time.NewTicker(o.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := o.ProcessPending(ctx); err != nil { + slog.Warn("outbox worker failed", "err", err) + } + } + } +} + +func strPtrOrNil(value string) *string { + if value == "" { + return nil + } + return &value +} + +func randomEventID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano))) + } + return hex.EncodeToString(b) +} diff --git a/server-go/internal/service/outbox_test.go b/server-go/internal/service/outbox_test.go new file mode 100644 index 0000000..a7bd971 --- /dev/null +++ b/server-go/internal/service/outbox_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "silk-server-go/internal/model" +) + +func newOutboxMock(t *testing.T) (*Outbox, sqlmock.Sqlmock) { + t.Helper() + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{}) + if err != nil { + t.Fatalf("open gorm: %v", err) + } + o := NewOutbox(gdb) + return o, mock +} + +func TestPublishTxUsesSameEventIDIdempotently(t *testing.T) { + o, mock := newOutboxMock(t) + event := Event{ + ID: "event-1", + Type: OutboxEventWechatSubscribe, + Payload: json.RawMessage(`{"userId":"u1"}`), + } + mock.ExpectQuery(`INSERT INTO "outbox_events".*ON CONFLICT \("event_id"\) DO NOTHING`). + WithArgs( + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + ). + WillReturnRows(sqlmock.NewRows([]string{"id", "payload", "next_attempt_at"}). + AddRow("00000000-0000-0000-0000-000000000001", json.RawMessage(`{"userId":"u1"}`), time.Now())) + if err := o.PublishTx(o.db, event); err != nil { + t.Fatalf("first publish failed: %v", err) + } + // 第二次相同 eventId 仍走 ON CONFLICT DO NOTHING,不产生重复发送。 + mock.ExpectQuery(`INSERT INTO "outbox_events".*ON CONFLICT \("event_id"\) DO NOTHING`). + WithArgs( + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), + ). + WillReturnRows(sqlmock.NewRows([]string{"id", "payload", "next_attempt_at"})) + if err := o.PublishTx(o.db, event); err != nil { + t.Fatalf("second publish failed: %v", err) + } +} + +func TestProcessPendingWithNoEventsIsNoop(t *testing.T) { + o, mock := newOutboxMock(t) + o.handler = func(ctx context.Context, event model.OutboxEvent) error { return nil } + mock.ExpectQuery(`SELECT .* FROM "outbox_events" WHERE status IN \(.*\)`). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "event_id", "event_type", "aggregate_type", "aggregate_id", + "payload", "status", "attempts", "max_attempts", "next_attempt_at", + "last_error", "created_at", "updated_at", + })) + processed, err := o.ProcessPending(context.Background()) + if err != nil { + t.Fatalf("ProcessPending failed: %v", err) + } + if processed != 0 { + t.Fatalf("processed = %d, want 0", processed) + } +} + +func TestClaimNextReturnsPendingAfterRestart(t *testing.T) { + o, mock := newOutboxMock(t) + now := time.Now() + mock.ExpectQuery(`SELECT .* FROM "outbox_events" WHERE status IN \(.*\)`). + WithArgs(OutboxStatusPending, OutboxStatusRetry, sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "event_id", "event_type", "aggregate_type", "aggregate_id", + "payload", "status", "attempts", "max_attempts", "next_attempt_at", + "last_error", "created_at", "updated_at", + }).AddRow( + "id-1", "event-1", OutboxEventWechatSubscribe, nil, nil, + json.RawMessage(`{}`), OutboxStatusPending, 0, 5, now, + nil, now, now, + )) + events, err := o.ClaimNext(context.Background(), 1) + if err != nil { + t.Fatalf("ClaimNext failed: %v", err) + } + if len(events) != 1 || events[0].EventID != "event-1" { + t.Fatalf("events = %+v", events) + } +} diff --git a/server-go/internal/service/wechat.go b/server-go/internal/service/wechat.go index f903b8c..8dfaaa7 100644 --- a/server-go/internal/service/wechat.go +++ b/server-go/internal/service/wechat.go @@ -10,6 +10,11 @@ import ( "net/url" "sync" "time" + + "silk-server-go/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" ) const wechatAPIBase = "https://api.weixin.qq.com" @@ -168,9 +173,21 @@ func (s *WechatService) getAccessToken(ctx context.Context) (string, error) { // SendSubscribe 发送订阅消息 func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) error { + _, err := s.SendSubscribeResult(ctx, openid, templateID, data, page) + return err +} + +// WechatSendResult 微信订阅消息响应。 +type WechatSendResult struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// SendSubscribeResult 发送订阅消息并返回微信响应码。 +func (s *WechatService) SendSubscribeResult(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) (WechatSendResult, error) { token, err := s.getAccessToken(ctx) if err != nil { - return err + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } payload := map[string]any{ "touser": openid, @@ -182,32 +199,142 @@ func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID st } raw, err := json.Marshal(payload) if err != nil { - return err + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } u := s.baseURL + "/cgi-bin/message/subscribe/send?access_token=" + url.QueryEscape(token) req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw)) if err != nil { - return err + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient.Do(req) if err != nil { - return err + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return err - } - var out struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } + var out WechatSendResult if err := json.Unmarshal(body, &out); err != nil { - return err + return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err } if out.ErrCode != 0 { - return fmt.Errorf("微信订阅消息发送失败 (%d): %s", out.ErrCode, out.ErrMsg) + return out, fmt.Errorf("微信订阅消息发送失败 (%d): %s", out.ErrCode, out.ErrMsg) } - return nil + return out, nil +} + +// WechatSubscribePayload outbox 微信订阅事件载荷。 +type WechatSubscribePayload struct { + UserID string `json:"userId"` + OpenID string `json:"openId"` + TemplateKey string `json:"templateKey"` + TemplateID string `json:"templateId"` + Page string `json:"page"` + Title string `json:"title"` + Body string `json:"body"` + Data map[string]map[string]string `json:"data"` + BusinessType string `json:"businessType"` + BusinessID string `json:"businessId"` +} + +// NewWechatOutboxHandler 创建微信订阅 outbox 处理器。 +func NewWechatOutboxHandler(db *gorm.DB, wechat *WechatService) EventHandler { + return func(ctx context.Context, event model.OutboxEvent) error { + if event.EventType != OutboxEventWechatSubscribe { + return nil + } + var payload WechatSubscribePayload + if err := json.Unmarshal(event.Payload, &payload); err != nil { + return err + } + return handleWechatSubscribe(ctx, db, wechat, event, payload) + } +} + +func handleWechatSubscribe(ctx context.Context, db *gorm.DB, wechat *WechatService, event model.OutboxEvent, payload WechatSubscribePayload) error { + var binding model.WechatBinding + if err := db.Where("user_id = ?", payload.UserID).First(&binding).Error; err != nil { + message := "微信未绑定" + return saveWechatNotification(ctx, db, event, payload, "", false, WechatSendResult{ErrCode: 40001, ErrMsg: message}, "cancelled", &message) + } + + var authorized []string + if len(binding.AuthorizedTemplates) > 0 { + _ = json.Unmarshal(binding.AuthorizedTemplates, &authorized) + } + if !IsAuthorized(authorized, payload.TemplateKey) { + message := "用户未授权订阅模板" + return saveWechatNotification(ctx, db, event, payload, binding.OpenID, false, WechatSendResult{ErrCode: 43101, ErrMsg: message}, "cancelled", &message) + } + + if payload.TemplateID == "" { + message := "订阅消息模板 ID 未配置" + if err := saveWechatNotification(ctx, db, event, payload, binding.OpenID, true, WechatSendResult{ErrCode: -1, ErrMsg: message}, "retry", &message); err != nil { + return err + } + return fmt.Errorf("订阅消息模板 ID 未配置") + } + + result, sendErr := wechat.SendSubscribeResult(ctx, binding.OpenID, payload.TemplateID, payload.Data, payload.Page) + status := "sent" + var lastErr *string + maxAttempts := event.MaxAttempts + if maxAttempts <= 0 { + maxAttempts = 5 + } + if sendErr != nil { + message := sendErr.Error() + lastErr = &message + status = "retry" + if event.Attempts+1 >= maxAttempts { + status = "failed" + } + } + if err := saveWechatNotification(ctx, db, event, payload, binding.OpenID, true, result, status, lastErr); err != nil { + return err + } + return sendErr +} + +func saveWechatNotification(ctx context.Context, db *gorm.DB, event model.OutboxEvent, payload WechatSubscribePayload, openID string, authorized bool, response WechatSendResult, status string, lastErr *string) error { + data, _ := json.Marshal(payload.Data) + providerResponse, _ := json.Marshal(response) + userID := payload.UserID + eventID := event.EventID + notification := model.Notification{ + UserID: &userID, + Channel: "wechat", + Target: openID, + Title: payload.Title, + Body: payload.Body, + Status: status, + Attempts: event.Attempts + 1, + NextAttemptAt: time.Now(), + LastError: lastErr, + TemplateID: strPtrOrNil(payload.TemplateID), + OpenID: strPtrOrNil(openID), + Authorized: authorized, + ProviderResponse: providerResponse, + Data: data, + BusinessType: strPtrOrNil(payload.BusinessType), + BusinessID: strPtrOrNil(payload.BusinessID), + EventID: &eventID, + } + return db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "event_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "status": status, + "attempts": event.Attempts + 1, + "next_attempt_at": time.Now(), + "last_error": lastErr, + "authorized": authorized, + "provider_response": providerResponse, + "updated_at": time.Now(), + }), + }). + Create(¬ification).Error } diff --git a/server-go/internal/service/wechat_test.go b/server-go/internal/service/wechat_test.go index cce431a..4de7f30 100644 --- a/server-go/internal/service/wechat_test.go +++ b/server-go/internal/service/wechat_test.go @@ -106,7 +106,11 @@ func TestWechatSendSubscribeErrorCode(t *testing.T) { s.baseURL = srv.URL s.accessToken = "fake-token" s.tokenExpire = time.Now().Add(time.Hour) - if err := s.SendSubscribe(context.Background(), "bad", "tmpl1", nil, ""); err == nil { + result, err := s.SendSubscribeResult(context.Background(), "bad", "tmpl1", nil, "") + if err == nil { t.Error("errcode!=0 应返回错误") } + if result.ErrCode != 40003 || result.ErrMsg != "invalid openid" { + t.Errorf("response code = %+v", result) + } } diff --git a/server-go/migrations/000004_notifications_outbox.down.sql b/server-go/migrations/000004_notifications_outbox.down.sql new file mode 100644 index 0000000..4294fce --- /dev/null +++ b/server-go/migrations/000004_notifications_outbox.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notifications CASCADE; +DROP TABLE IF EXISTS outbox_events CASCADE; diff --git a/server-go/migrations/000004_notifications_outbox.up.sql b/server-go/migrations/000004_notifications_outbox.up.sql new file mode 100644 index 0000000..2f8b54f --- /dev/null +++ b/server-go/migrations/000004_notifications_outbox.up.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS outbox_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id varchar(128) NOT NULL, + event_type varchar(64) NOT NULL, + aggregate_type varchar(64), + aggregate_id varchar(128), + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(16) NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + max_attempts integer NOT NULL DEFAULT 5, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_outbox_events_event_id ON outbox_events (event_id); +CREATE INDEX IF NOT EXISTS idx_outbox_events_status_next + ON outbox_events (status, next_attempt_at); + +CREATE TABLE IF NOT EXISTS notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid, + channel varchar(32) NOT NULL, + target varchar(128) NOT NULL, + title varchar(128) NOT NULL, + body text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + template_id varchar(128), + open_id varchar(128), + authorized boolean NOT NULL DEFAULT false, + provider_response jsonb, + data jsonb, + business_type varchar(64), + business_id varchar(128), + event_id varchar(128), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_event_id ON notifications (event_id); +CREATE INDEX IF NOT EXISTS idx_notifications_user_created ON notifications (user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_notifications_status_next + ON notifications (status, next_attempt_at); diff --git a/后续工作计划.md b/后续工作计划.md index 661c03f..5de9863 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -1,6 +1,6 @@ # 后续工作计划 -> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 +> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 ## 整改实施计划 Wave 0-4(2026-08-13 启动) @@ -16,7 +16,7 @@ | Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 部分可用 | 待开发服务器部署与真实 WS/AI 联调 | | Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 | | Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 | -| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 未开始 | 无 | +| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 | | Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 | | Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 | | Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index f931dd2..23e0064 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -969,3 +969,32 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 ### 回滚点 - 本记录仅修改文档,无代码、数据库、服务器或生成产物变更;如需回滚,删除本记录并恢复相关计划/决策表即可。 + +## 2026-08-14 整改 Task 8:建立可靠通知、吊销与跨实例状态 + +### 做了什么 + +- 新增 `outbox_events`、`notifications` 表和 `000004_notifications_outbox` 迁移,通知列表从进程内存改为 PostgreSQL 持久化; +- 新增 `Outbox.PublishTx(tx, event)`:业务事务内写入事件,重复 `eventId` 通过唯一索引幂等;worker 领取、发送、退避重试并记录 `pending/sending/sent/retry/failed/cancelled`; +- JWT 吊销与登录限流迁移到 Redis:新增 `middleware.StateStore`/`RedisState`,Redis 不可用时登录、刷新、吊销和已认证接口返回 503,不再静默回退单机内存; +- 巡检风险微信推送改为事务内写 Outbox,不再使用进程内 goroutine;微信发送结果保存模板、用户授权、微信 `errcode/errmsg`、尝试次数和最终状态; +- `/notifications` 改读 PostgreSQL;手动通知也落库。 + +### 设计思路与决策依据 + +- 可靠通知不能依赖进程内存;PostgreSQL 保存事件与业务同事务,Redis 只保存短时效的吊销和限流状态,降低多实例一致性风险; +- Outbox 幂等由 `event_id` 唯一索引和 `ON CONFLICT DO NOTHING` 保证;worker 领取时用 `pending/retry -> sending` 原子更新,避免多实例重复处理; +- Redis 不可用时采用保守失败而不是降级:认证状态和限流丢失会造成越权或绕过风险,业务价值大于可用性损失; +- 微信未绑定或未授权不是临时失败,写入 `cancelled`;模板未配置或微信返回错误进入重试/最终失败,便于人工查看。 + +### 验证结果 + +- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过; +- 新增测试覆盖 Outbox 幂等发布、重启后领取 pending、Redis 状态不可用保守失败、吊销跨状态、登录锁定/清空和微信响应码解析; +- 未部署开发服务器,未执行 `000004` 迁移;未做真实 Redis/微信联调和进程重启演练。 + +### 回滚点 + +- 本任务前分支提交为 `a6a996a`;回滚可还原 Task 8 提交; +- 数据库回滚执行 `000004_notifications_outbox.down.sql`,可删除 `notifications` 和 `outbox_events`;未发送的 Outbox 数据会随回滚丢失,回滚前必须先备份或暂停 worker; +- 认证状态回滚需恢复旧二进制,Redis 中的吊销/限流键由 TTL 自然过期。