feat: 建立可靠通知、吊销与跨实例状态
This commit is contained in:
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
### 1.6 通知与实时推送
|
### 1.6 通知与实时推送
|
||||||
|
|
||||||
- WebSocket 网关位于 `/ws`,使用 JWT 鉴权
|
- WebSocket 网关位于 `/ws`,使用一次性 ticket 鉴权
|
||||||
- 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery`
|
- 广播事件:`telemetry.all`、`telemetry`、`alarm`、`alarm.recovery`
|
||||||
- Web/APP/小程序均接入 WebSocket 实时订阅
|
- Web/APP/小程序均接入 WebSocket 实时订阅
|
||||||
- 登录后自动连接 WebSocket,登出自动断开
|
- 登录后自动连接 WebSocket,登出自动断开
|
||||||
@@ -244,7 +244,7 @@ silk/
|
|||||||
| Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 |
|
| Alarms | `handler/alarm.go` | 告警生成、确认、解除、查询 |
|
||||||
| AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 |
|
| AlarmClip | `handler/alarm_clip.go` | 告警关联视频片段 |
|
||||||
| Control | `handler/control.go` | MQTT 控制命令下发 |
|
| Control | `handler/control.go` | MQTT 控制命令下发 |
|
||||||
| Notification | `handler/notification.go` | Redis 站内通知 |
|
| Notification | `handler/notification.go` | PostgreSQL 持久化通知与 Outbox 状态 |
|
||||||
| Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 |
|
| Video Camera | `handler/video_camera.go` | 摄像头管理、WVP 状态同步 |
|
||||||
| Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 |
|
| Video Stream | `handler/video_stream.go` | 实时点播、FLV 代理 |
|
||||||
| Video Record | `handler/video_record.go` | 录制启停、活跃录制查询 |
|
| Video Record | `handler/video_record.go` | 录制启停、活跃录制查询 |
|
||||||
@@ -442,9 +442,9 @@ IOTDB_URL=http://localhost:18081
|
|||||||
|
|
||||||
IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature`
|
IoTDB 路径示例:`root.silk.telemetry.`sensor-001`.temperature`
|
||||||
|
|
||||||
### 10.3 Valkey/Redis(缓存与通知)
|
### 10.3 Valkey/Redis(跨实例认证状态)
|
||||||
|
|
||||||
用于权限缓存(5 分钟有效期)、站内通知列表。
|
用于 JWT 吊销、登录限流等跨实例状态;通知与事件箱持久化在 PostgreSQL,Redis 不可用时登录/刷新/吊销和已认证接口保守失败,不回退单机内存。
|
||||||
|
|
||||||
### 10.4 Ceph S3(录像存储)
|
### 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` |
|
| `APP_ENV` | `development` | 运行环境;生产环境应设为 `production` |
|
||||||
| `ALLOW_DEV_AUTOMIGRATE` | `false` | 仅开发环境可显式开启 AutoMigrate,生产忽略此开关 |
|
| `ALLOW_DEV_AUTOMIGRATE` | `false` | 仅开发环境可显式开启 AutoMigrate,生产忽略此开关 |
|
||||||
| `WS_ALLOWED_ORIGINS` | `http://localhost:5174,http://localhost:3000,...` | WebSocket 允许的 Origin,逗号分隔 |
|
| `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_SECRET` | `silk-secret-please-change-me` | JWT 签名密钥 |
|
||||||
| `JWT_EXPIRES_IN` | `2h` | JWT 有效期 |
|
| `JWT_EXPIRES_IN` | `2h` | JWT 有效期 |
|
||||||
| `MQTT` | `mqtt://pan:pan@localhost:1883` | MQTT 连接串 |
|
| `MQTT` | `mqtt://pan:pan@localhost:1883` | MQTT 连接串 |
|
||||||
|
|||||||
@@ -42,14 +42,24 @@ func main() {
|
|||||||
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
slog.Warn("IoTDB 初始化失败,将降级使用 PostgreSQL", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 连接 Redis(失败不阻断启动)
|
// 4. 连接 Redis(失败不阻断启动,但认证/限流保守失败)
|
||||||
|
var rdb *redis.Client
|
||||||
if opt, err := redis.ParseURL(cfg.Redis); err == nil {
|
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 {
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
slog.Warn("Redis 连接失败", "err", err)
|
slog.Error("Redis 连接失败,认证/限流将保守失败", "err", err)
|
||||||
|
_ = rdb.Close()
|
||||||
|
rdb = nil
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Redis 连接成功")
|
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
|
// 5. 创建 WebSocket Hub
|
||||||
@@ -76,6 +86,11 @@ func main() {
|
|||||||
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
aiSvc := service.NewAIClient(cfg.AIServiceBase)
|
||||||
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
||||||
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
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 引擎
|
// 9. 创建 Gin 引擎
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -111,7 +126,7 @@ func main() {
|
|||||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||||
handler.RegisterStorageRoutes(api, db)
|
handler.RegisterStorageRoutes(api, db)
|
||||||
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
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.RegisterTrayBatchRoutes(api, db)
|
||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.Permission{}, &model.RolePermission{},
|
&model.Permission{}, &model.RolePermission{},
|
||||||
&model.Disease{}, &model.KnowledgeArticle{},
|
&model.Disease{}, &model.KnowledgeArticle{},
|
||||||
&model.InspectionRecord{},
|
&model.InspectionRecord{},
|
||||||
|
&model.OutboxEvent{},
|
||||||
|
&model.Notification{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "2"
|
const CurrentSchemaVersion = "4"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -104,4 +104,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if err != nil || next != 2 {
|
if err != nil || next != 2 {
|
||||||
t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func registerHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色)
|
// 强制角色为 viewer,防止垂直越权(注册接口不允许自选角色)
|
||||||
role := model.RoleViewer
|
role := model.RoleViewer
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Username: body.Username,
|
Username: body.Username,
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
@@ -123,13 +123,19 @@ func loginHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if db.Where("username = ? OR email = ?", body.Username, body.Username).First(&user).Error != nil {
|
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": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
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": "用户名或密码错误"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
return
|
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
|
uid := user.ID
|
||||||
@@ -184,7 +193,12 @@ func refreshHandler(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效"})
|
||||||
return
|
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": "刷新令牌已注销"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌已注销"})
|
||||||
return
|
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))
|
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 len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||||
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
if claims, token, err := middleware.ExtractClaims(parts[1], cfg.JWTSecret); err == nil && token.Valid {
|
||||||
if claims.ExpiresAt != nil {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -27,8 +27,8 @@ func isUUID(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInspectionRoutes 注册 AI 巡检路由
|
// 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) {
|
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, wechat, inspectionTemplateID, appEnv))
|
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))
|
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 → 写记录。
|
// createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。
|
||||||
// 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。
|
// 幂等:客户端传 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) {
|
return func(c *gin.Context) {
|
||||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
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)
|
rawRisk, _ := json.Marshal(assessment)
|
||||||
rec.RiskAssessment = rawRisk
|
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 != "" {
|
if idemKey != "" {
|
||||||
var exist model.InspectionRecord
|
var exist model.InspectionRecord
|
||||||
|
|||||||
@@ -1,61 +1,34 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"silk-server-go/internal/middleware"
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"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 注册通知路由
|
// RegisterNotificationRoutes 注册通知路由
|
||||||
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
func RegisterNotificationRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
readPerm := middleware.RequirePermission(db, "alarm:read")
|
readPerm := middleware.RequirePermission(db, "alarm:read")
|
||||||
rg.GET("/notifications", readPerm, listNotifications())
|
rg.GET("/notifications", readPerm, listNotifications(db))
|
||||||
rg.POST("/notifications", readPerm, createNotification())
|
rg.POST("/notifications", readPerm, createNotification(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// listNotifications 通知列表(上限200)
|
// listNotifications 通知列表(上限200)
|
||||||
func listNotifications() gin.HandlerFunc {
|
func listNotifications(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
notificationMu.Lock()
|
var list []model.Notification
|
||||||
defer notificationMu.Unlock()
|
db.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
limit := 200
|
|
||||||
if len(notificationStore) < limit {
|
|
||||||
limit = len(notificationStore)
|
|
||||||
}
|
|
||||||
// 返回最新的 limit 条(存储已按新到旧排序)
|
|
||||||
result := make([]notificationItem, limit)
|
|
||||||
copy(result, notificationStore[:limit])
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createNotification 手动发通知
|
// createNotification 手动发通知
|
||||||
func createNotification() gin.HandlerFunc {
|
func createNotification(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
@@ -68,24 +41,25 @@ func createNotification() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ntf := notificationItem{
|
ntf := model.Notification{
|
||||||
ID: fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(1000000)),
|
UserID: currentUserID(c),
|
||||||
Channel: body.Channel,
|
Channel: body.Channel,
|
||||||
Target: body.Target,
|
Target: body.Target,
|
||||||
Title: body.Title,
|
Title: body.Title,
|
||||||
Body: body.Body,
|
Body: body.Body,
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
Status: "sent",
|
||||||
|
NextAttemptAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if ntf.Channel == "" {
|
||||||
notificationMu.Lock()
|
ntf.Channel = "manual"
|
||||||
// 插入到头部(最新在前)
|
}
|
||||||
notificationStore = append([]notificationItem{ntf}, notificationStore...)
|
if ntf.Target == "" {
|
||||||
// 保留最近 500 条
|
ntf.Target = "all"
|
||||||
if len(notificationStore) > 500 {
|
}
|
||||||
notificationStore = notificationStore[:500]
|
if err := db.Create(&ntf).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建通知失败"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
notificationMu.Unlock()
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, ntf)
|
c.JSON(http.StatusCreated, ntf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ type JWTClaims struct {
|
|||||||
|
|
||||||
// 白名单路径,无需鉴权
|
// 白名单路径,无需鉴权
|
||||||
var whitelist = map[string]bool{
|
var whitelist = map[string]bool{
|
||||||
"/api/v1/health": true,
|
"/api/v1/health": true,
|
||||||
"/health": true,
|
"/health": true,
|
||||||
"/api/health": true,
|
"/api/health": true,
|
||||||
"/api/v1/auth/login": true,
|
"/api/v1/auth/login": true,
|
||||||
"/auth/login": true,
|
"/auth/login": true,
|
||||||
"/api/v1/auth/register": true,
|
"/api/v1/auth/register": true,
|
||||||
"/auth/register": true,
|
"/auth/register": true,
|
||||||
"/api/v1/auth/refresh": true,
|
"/api/v1/auth/refresh": true,
|
||||||
"/auth/refresh": true,
|
"/auth/refresh": true,
|
||||||
"/api/v1/video/clips/internal": true,
|
"/api/v1/video/clips/internal": true,
|
||||||
"/api/v1/video/recordings/internal/end": 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": "令牌已注销,请重新登录"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "令牌已注销,请重新登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,41 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 (
|
const (
|
||||||
maxFailures = 5 // 连续失败 5 次后锁定
|
maxFailures = 5 // 连续失败 5 次后锁定
|
||||||
lockDuration = 15 * time.Minute
|
lockDuration = 15 * time.Minute
|
||||||
failureWindow = 10 * time.Minute // 失败计数窗口
|
failureWindow = 10 * time.Minute // 失败计数窗口
|
||||||
cleanupInterval = 5 * 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(小写)
|
// key = ip + "|" + username(小写)
|
||||||
func limiterKey(c *gin.Context, username string) string {
|
func limiterKey(c *gin.Context, username string) string {
|
||||||
return c.ClientIP() + "|" + strings.ToLower(strings.TrimSpace(username))
|
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 后调用)
|
// CheckLoginLock 检查是否被锁定,被锁定则写 429 并返回 true(在 handler 解析 body 后调用)
|
||||||
func CheckLoginLock(c *gin.Context, username string) bool {
|
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)
|
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{
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||||
"error": "登录尝试过多,已锁定,请稍后再试",
|
"error": "登录尝试过多,已锁定,请稍后再试",
|
||||||
"retry": int(remain.Minutes()) + 1,
|
"retry": int(remain.Minutes()) + 1,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -112,11 +43,17 @@ func CheckLoginLock(c *gin.Context, username string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordLoginFail 记录登录失败
|
// RecordLoginFail 记录登录失败
|
||||||
func RecordLoginFail(c *gin.Context, username string) {
|
func RecordLoginFail(c *gin.Context, username string) error {
|
||||||
defaultLoginLimiter.recordFailure(limiterKey(c, username))
|
if authState == nil {
|
||||||
|
return stateUnavailable("记录登录失败")
|
||||||
|
}
|
||||||
|
return authState.RecordLoginFailure(context.Background(), limiterKey(c, username))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordLoginSuccess 登录成功后清空计数
|
// RecordLoginSuccess 登录成功后清空计数
|
||||||
func RecordLoginSuccess(c *gin.Context, username string) {
|
func RecordLoginSuccess(c *gin.Context, username string) error {
|
||||||
defaultLoginLimiter.recordSuccess(limiterKey(c, username))
|
if authState == nil {
|
||||||
|
return stateUnavailable("清空登录失败计数")
|
||||||
|
}
|
||||||
|
return authState.RecordLoginSuccess(context.Background(), limiterKey(c, username))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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+签发时间)
|
// RevokeToken 将令牌加入黑名单(按 jti,若无 jti 则按 subject+签发时间)
|
||||||
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) {
|
func RevokeToken(claims *JWTClaims, tokenStr string, exp time.Time) error {
|
||||||
id := tokenIdentifier(claims)
|
if authState == nil {
|
||||||
defaultBlacklist.mu.Lock()
|
return stateUnavailable("吊销令牌")
|
||||||
defaultBlacklist.revoked[id] = exp
|
}
|
||||||
defaultBlacklist.mu.Unlock()
|
return authState.RevokeToken(context.Background(), tokenIdentifier(claims), exp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRevoked 判断令牌是否已被吊销
|
// IsRevoked 判断令牌是否已被吊销
|
||||||
func IsRevoked(claims *JWTClaims) bool {
|
func IsRevoked(claims *JWTClaims) (bool, error) {
|
||||||
id := tokenIdentifier(claims)
|
if authState == nil {
|
||||||
defaultBlacklist.mu.RLock()
|
return false, stateUnavailable("校验令牌吊销状态")
|
||||||
exp, ok := defaultBlacklist.revoked[id]
|
|
||||||
defaultBlacklist.mu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
// 已过期的黑名单项自动清理
|
return authState.IsTokenRevoked(context.Background(), tokenIdentifier(claims))
|
||||||
if time.Now().After(exp) {
|
|
||||||
defaultBlacklist.mu.Lock()
|
|
||||||
delete(defaultBlacklist.revoked, id)
|
|
||||||
defaultBlacklist.mu.Unlock()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
// ExtractClaims 从 token 字符串解析 claims(供 logout handler 使用)
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,11 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
const wechatAPIBase = "https://api.weixin.qq.com"
|
const wechatAPIBase = "https://api.weixin.qq.com"
|
||||||
@@ -168,9 +173,21 @@ func (s *WechatService) getAccessToken(ctx context.Context) (string, error) {
|
|||||||
|
|
||||||
// SendSubscribe 发送订阅消息
|
// SendSubscribe 发送订阅消息
|
||||||
func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID string, data map[string]map[string]string, page string) error {
|
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)
|
token, err := s.getAccessToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"touser": openid,
|
"touser": openid,
|
||||||
@@ -182,32 +199,142 @@ func (s *WechatService) SendSubscribe(ctx context.Context, openid, templateID st
|
|||||||
}
|
}
|
||||||
raw, err := json.Marshal(payload)
|
raw, err := json.Marshal(payload)
|
||||||
if err != nil {
|
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)
|
u := s.baseURL + "/cgi-bin/message/subscribe/send?access_token=" + url.QueryEscape(token)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := s.httpClient.Do(req)
|
resp, err := s.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
ErrCode int `json:"errcode"`
|
|
||||||
ErrMsg string `json:"errmsg"`
|
|
||||||
}
|
}
|
||||||
|
var out WechatSendResult
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
return err
|
return WechatSendResult{ErrCode: -1, ErrMsg: err.Error()}, err
|
||||||
}
|
}
|
||||||
if out.ErrCode != 0 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ func TestWechatSendSubscribeErrorCode(t *testing.T) {
|
|||||||
s.baseURL = srv.URL
|
s.baseURL = srv.URL
|
||||||
s.accessToken = "fake-token"
|
s.accessToken = "fake-token"
|
||||||
s.tokenExpire = time.Now().Add(time.Hour)
|
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 应返回错误")
|
t.Error("errcode!=0 应返回错误")
|
||||||
}
|
}
|
||||||
|
if result.ErrCode != 40003 || result.ErrMsg != "invalid openid" {
|
||||||
|
t.Errorf("response code = %+v", result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS notifications CASCADE;
|
||||||
|
DROP TABLE IF EXISTS outbox_events CASCADE;
|
||||||
@@ -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);
|
||||||
@@ -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 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
| Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 部分可用 | 待开发服务器部署与真实 WS/AI 联调 |
|
| Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 部分可用 | 待开发服务器部署与真实 WS/AI 联调 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 |
|
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
||||||
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 未开始 | 无 |
|
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
||||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
|
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
|
||||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 |
|
||||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
||||||
|
|||||||
@@ -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 自然过期。
|
||||||
|
|||||||
Reference in New Issue
Block a user