feat: 建立可靠通知、吊销与跨实例状态
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user