diff --git a/Bell/server/app/bell/contact/crypto.go b/Bell/server/app/bell/contact/crypto.go new file mode 100644 index 0000000..fa86cd5 --- /dev/null +++ b/Bell/server/app/bell/contact/crypto.go @@ -0,0 +1,59 @@ +package contact + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "strings" +) + +var ErrChannelKeyUnavailable = errors.New("联系人通道加密密钥未配置或格式错误") + +func ParseChannelKey(value string) ([]byte, error) { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(key) != 32 { + return nil, ErrChannelKeyUnavailable + } + return key, nil +} + +func encryptAddress(key []byte, value string) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, ErrChannelKeyUnavailable + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, []byte(value), nil), nil +} + +func decryptAddress(key, encoded []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", ErrChannelKeyUnavailable + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(encoded) < gcm.NonceSize() { + return "", errors.New("通道密文已损坏") + } + plain, err := gcm.Open(nil, encoded[:gcm.NonceSize()], encoded[gcm.NonceSize():], nil) + return string(plain), err +} + +func fingerprint(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/Bell/server/app/bell/contact/handler.go b/Bell/server/app/bell/contact/handler.go new file mode 100644 index 0000000..ac50dd1 --- /dev/null +++ b/Bell/server/app/bell/contact/handler.go @@ -0,0 +1,146 @@ +package contact + +import ( + "errors" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" +) + +type Handler struct{ api.Api } +type enabledInput struct { + Enabled *bool `json:"enabled" binding:"required"` + ExpectedVersion int `json:"expectedVersion" binding:"required"` +} +type validationInput struct { + Status string `json:"status"` + Detail string `json:"detail"` +} + +func (h Handler) service() (Service, error) { + key, err := ParseChannelKey(os.Getenv("BELL_CONTACT_CHANNEL_KEY")) + return NewService(h.Orm, key), err +} +func (h Handler) List(c *gin.Context) { + var q PageQuery + h.MakeContext(c).MakeOrm().Bind(&q, binding.Form) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + items, count, err := NewService(h.Orm, nil).List(c.Request.Context(), q) + if err != nil { + h.Error(500, errors.New("读取联系人失败"), "读取联系人失败") + return + } + p, s := pageValues(q.PageIndex, q.PageSize) + h.PageOK(items, int(count), p, s, "查询成功") +} +func (h Handler) Create(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人") + return + } + var input WriteInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm, nil).Create(c.Request.Context(), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) Update(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人") + return + } + var input WriteInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm, nil).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) SetEnabled(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人") + return + } + var input enabledInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil || input.Enabled == nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm, nil).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, input.ExpectedVersion, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) AddChannel(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护通道"), "仅管理员可维护通道") + return + } + if err := restoreChannelBody(c); err != nil { + h.MakeContext(c).Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error()) + return + } + var input ChannelInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + service, keyErr := h.service() + if keyErr != nil { + h.Error(503, keyErr, keyErr.Error()) + return + } + item, err := service.AddChannel(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) ValidateChannel(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可验证通道"), "仅管理员可验证通道") + return + } + var input validationInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm, nil).RecordValidation(c.Request.Context(), c.Param("id"), input.Status, input.Detail, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) result(item any, err error) { + switch { + case err == nil: + h.OK(item, "保存成功") + case errors.Is(err, ErrInvalid): + h.Error(400, err, err.Error()) + case errors.Is(err, ErrNotFound): + h.Error(404, err, err.Error()) + case errors.Is(err, ErrConflict): + h.Error(409, err, err.Error()) + case errors.Is(err, ErrChannelKeyUnavailable): + h.Error(503, err, err.Error()) + default: + h.Logger.Errorf("write Bell contact failed: %v", err) + h.Error(409, errors.New("联系人保存失败"), "联系人保存失败") + } +} +func admin(c *gin.Context) bool { + claims := jwt.ExtractClaims(c) + role, _ := claims[jwt.RoleKey].(string) + return role == "admin" +} + +var _ = http.StatusOK diff --git a/Bell/server/app/bell/contact/model.go b/Bell/server/app/bell/contact/model.go new file mode 100644 index 0000000..9e6d311 --- /dev/null +++ b/Bell/server/app/bell/contact/model.go @@ -0,0 +1,68 @@ +package contact + +import ( + "encoding/json" + "time" +) + +type Contact struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + Name string `json:"name" gorm:"size:128;not null"` + Role string `json:"role" gorm:"size:128;not null"` + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + Version int `json:"version" gorm:"not null;default:1"` + CreatedBy int `json:"createdBy" gorm:"not null"` + UpdatedBy int `json:"updatedBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"` +} + +func (Contact) TableName() string { return "bell_contacts" } + +type Channel struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + ContactID string `json:"contactId" gorm:"type:uuid;not null;index"` + Kind string `json:"kind" gorm:"size:16;not null"` + AddressCiphertext []byte `json:"-" gorm:"type:bytea;not null"` + AddressFingerprint string `json:"-" gorm:"size:64;not null;index"` + AddressMasked string `json:"addressMasked" gorm:"size:64;not null"` + CreatedBy int `json:"createdBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` +} + +func (Channel) TableName() string { return "bell_contact_channels" } + +type ChannelValidation struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + ChannelID string `json:"channelId" gorm:"type:uuid;not null;index"` + Status string `json:"status" gorm:"size:16;not null"` + Detail string `json:"detail" gorm:"size:256;not null"` + ActorID int `json:"actorId" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"` +} + +func (ChannelValidation) TableName() string { return "bell_contact_channel_validations" } + +type AuditFact struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + ContactID string `json:"contactId" gorm:"type:uuid;not null;index"` + Action string `json:"action" gorm:"size:32;not null"` + Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"` + ActorID int `json:"actorId" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` +} + +func (AuditFact) TableName() string { return "bell_contact_audit_facts" } + +type ChannelView struct { + ID string `json:"id"` + Kind string `json:"kind"` + AddressMasked string `json:"addressMasked"` + Status string `json:"status"` + ValidatedAt *time.Time `json:"validatedAt,omitempty"` +} + +type ContactView struct { + Contact + Channels []ChannelView `json:"channels"` +} diff --git a/Bell/server/app/bell/contact/redaction.go b/Bell/server/app/bell/contact/redaction.go new file mode 100644 index 0000000..43fbde3 --- /dev/null +++ b/Bell/server/app/bell/contact/redaction.go @@ -0,0 +1,62 @@ +package contact + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const maxChannelRequestBytes = 8 * 1024 +const channelBodyKey = "bell.contact.channel-body" +const channelBodyErrorKey = "bell.contact.channel-body-error" + +var redactedChannelBody = []byte(`{"redacted":true}`) + +// RedactRequestBody must run before GoAdmin's LoggerToFile middleware. The +// handler restores the original body from Gin context, while sys_opera_log +// only sees a fixed marker and never the contact address. +func RedactRequestBody() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method != http.MethodPost || !isChannelCreatePath(c.Request.URL.Path) { + c.Next() + return + } + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxChannelRequestBytes+1)) + if err != nil { + c.Set(channelBodyErrorKey, err) + } else if len(body) > maxChannelRequestBytes { + c.Set(channelBodyErrorKey, errors.New("request body too large")) + } else { + c.Set(channelBodyKey, body) + } + _ = c.Request.Body.Close() + c.Request.Body = io.NopCloser(bytes.NewReader(redactedChannelBody)) + c.Next() + } +} + +func restoreChannelBody(c *gin.Context) error { + if value, ok := c.Get(channelBodyErrorKey); ok { + return value.(error) + } + value, ok := c.Get(channelBodyKey) + if !ok { + return errors.New("channel request body was not captured") + } + c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte))) + return nil +} + +func isChannelCreatePath(path string) bool { + const prefix = "/api/v1/bell/contacts/" + const suffix = "/channels" + if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) { + return false + } + id := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix) + return id != "" && !strings.Contains(id, "/") +} diff --git a/Bell/server/app/bell/contact/service.go b/Bell/server/app/bell/contact/service.go new file mode 100644 index 0000000..90381b0 --- /dev/null +++ b/Bell/server/app/bell/contact/service.go @@ -0,0 +1,251 @@ +package contact + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type PageQuery struct { + PageIndex int `form:"pageIndex"` + PageSize int `form:"pageSize"` + Name string `form:"name"` + Enabled *bool `form:"enabled"` +} +type Service struct { + DB *gorm.DB + Key []byte +} + +func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} } + +func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) { + page, size := pageValues(query.PageIndex, query.PageSize) + db := s.DB.WithContext(ctx).Model(&Contact{}) + if name := strings.TrimSpace(query.Name); name != "" { + db = db.Where("name ILIKE ? OR role ILIKE ?", "%"+name+"%", "%"+name+"%") + } + if query.Enabled != nil { + db = db.Where("enabled = ?", *query.Enabled) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return nil, 0, err + } + var contacts []Contact + if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil { + return nil, 0, err + } + views := make([]ContactView, 0, len(contacts)) + for _, item := range contacts { + view, err := s.view(ctx, item) + if err != nil { + return nil, 0, err + } + views = append(views, view) + } + return views, count, nil +} + +func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) { + input, err := normalizeContact(input) + if err != nil { + return ContactView{}, err + } + now := time.Now().UTC() + item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now} + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&item).Error; err != nil { + return err + } + return appendAudit(tx, item, "created", actor) + }) + if err != nil { + return ContactView{}, err + } + return ContactView{Contact: item, Channels: []ChannelView{}}, nil +} + +func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) { + if _, err := uuid.Parse(id); err != nil { + return ContactView{}, ErrNotFound + } + input, err := normalizeContact(input) + if err != nil { + return ContactView{}, err + } + if input.ExpectedVersion < 1 { + return ContactView{}, ErrInvalid + } + var item Contact + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + var count int64 + _ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error + if count == 0 { + return ErrNotFound + } + return ErrConflict + } + if err := tx.First(&item, "id = ?", id).Error; err != nil { + return err + } + return appendAudit(tx, item, "updated", actor) + }) + if err != nil { + return ContactView{}, err + } + return s.view(ctx, item) +} + +func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) { + if _, err := uuid.Parse(id); err != nil { + return ContactView{}, ErrNotFound + } + if expectedVersion < 1 { + return ContactView{}, ErrInvalid + } + var item Contact + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrConflict + } + if err := tx.First(&item, "id = ?", id).Error; err != nil { + return err + } + return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor) + }) + if err != nil { + return ContactView{}, err + } + return s.view(ctx, item) +} + +func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) { + if len(s.Key) != 32 { + return ChannelView{}, ErrChannelKeyUnavailable + } + if _, err := uuid.Parse(contactID); err != nil { + return ChannelView{}, ErrNotFound + } + input, err := normalizeChannel(input) + if err != nil { + return ChannelView{}, err + } + ciphertext, err := encryptAddress(s.Key, input.Address) + if err != nil { + return ChannelView{}, err + } + now := time.Now().UTC() + item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now} + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var contact Contact + if err := tx.First(&contact, "id = ?", contactID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + if err := tx.Create(&item).Error; err != nil { + return err + } + return appendAudit(tx, contact, "channel_added", actor) + }) + if err != nil { + return ChannelView{}, err + } + return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil +} + +func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) { + status = strings.ToLower(strings.TrimSpace(status)) + detail = strings.TrimSpace(detail) + if status != "verified" && status != "failed" { + return ChannelView{}, ErrInvalid + } + if len([]rune(detail)) > 256 || hasControl(detail) { + return ChannelView{}, ErrInvalid + } + var channel Channel + fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()} + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&channel, "id = ?", channelID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + if err := tx.Create(&fact).Error; err != nil { + return err + } + var c Contact + if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil { + return err + } + return appendAudit(tx, c, "channel_validation_"+status, actor) + }) + if err != nil { + return ChannelView{}, err + } + return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil +} + +// DecryptChannelAddress is intentionally server-only. API responses never expose this value. +func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) { + var item Channel + if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil { + return "", err + } + return decryptAddress(s.Key, item.AddressCiphertext) +} + +func (s Service) view(ctx context.Context, item Contact) (ContactView, error) { + var channels []Channel + if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil { + return ContactView{}, err + } + views := make([]ChannelView, 0, len(channels)) + for _, ch := range channels { + view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"} + var fact ChannelValidation + err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error + if err == nil { + view.Status = fact.Status + view.ValidatedAt = &fact.CreatedAt + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return ContactView{}, err + } + views = append(views, view) + } + return ContactView{Contact: item, Channels: views}, nil +} + +func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error { + snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version}) + if err != nil { + return err + } + return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error +} +func pageValues(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + return page, size +} diff --git a/Bell/server/app/bell/contact/validation.go b/Bell/server/app/bell/contact/validation.go new file mode 100644 index 0000000..d13c6c1 --- /dev/null +++ b/Bell/server/app/bell/contact/validation.go @@ -0,0 +1,60 @@ +package contact + +import ( + "errors" + "regexp" + "strings" +) + +var ( + ErrInvalid = errors.New("联系人或通道信息不符合要求") + ErrNotFound = errors.New("联系人或通道不存在") + ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试") + phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) +) + +type WriteInput struct { + Name string `json:"name"` + Role string `json:"role"` + ExpectedVersion int `json:"expectedVersion"` +} + +type ChannelInput struct { + Kind string `json:"kind"` + Address string `json:"address"` +} + +func normalizeContact(input WriteInput) (WriteInput, error) { + input.Name = strings.TrimSpace(input.Name) + input.Role = strings.TrimSpace(input.Role) + if input.Name == "" || len([]rune(input.Name)) > 128 || input.Role == "" || len([]rune(input.Role)) > 128 || hasControl(input.Name+input.Role) { + return WriteInput{}, ErrInvalid + } + return input, nil +} + +func normalizeChannel(input ChannelInput) (ChannelInput, error) { + input.Kind = strings.ToLower(strings.TrimSpace(input.Kind)) + input.Address = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(input.Address), " ", ""), "-", "") + if (input.Kind != "sms" && input.Kind != "voice") || !phonePattern.MatchString(input.Address) { + return ChannelInput{}, ErrInvalid + } + return input, nil +} + +func maskAddress(value string) string { + runes := []rune(value) + if len(runes) <= 4 { + return "****" + } + return strings.Repeat("*", min(8, len(runes)-4)) + string(runes[len(runes)-4:]) +} + +func hasControl(value string) bool { + for _, r := range value { + if r < 32 || r == 127 { + return true + } + } + return false +} diff --git a/Bell/server/app/bell/duty_schedule/handler.go b/Bell/server/app/bell/duty_schedule/handler.go new file mode 100644 index 0000000..28a5284 --- /dev/null +++ b/Bell/server/app/bell/duty_schedule/handler.go @@ -0,0 +1,127 @@ +package duty_schedule + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" +) + +type Handler struct{ api.Api } + +func (h Handler) List(c *gin.Context) { + var q PageQuery + h.MakeContext(c).MakeOrm().Bind(&q, binding.Form) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + return + } + items, count, err := NewService(h.Orm).List(c.Request.Context(), q) + if err != nil { + h.Error(500, errors.New("读取排班失败"), "读取排班失败") + return + } + p, s := pageValues(q.PageIndex, q.PageSize) + h.PageOK(items, int(count), p, s, "查询成功") +} +func (h Handler) CreateGroup(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班") + return + } + var input GroupInput + h.bind(c, &input) + if h.Errors != nil { + return + } + item, err := NewService(h.Orm).CreateGroup(c.Request.Context(), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) UpdateGroup(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班") + return + } + var input GroupInput + h.bind(c, &input) + if h.Errors != nil { + return + } + item, err := NewService(h.Orm).UpdateGroup(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) AddMember(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班") + return + } + var input MemberInput + h.bind(c, &input) + if h.Errors != nil { + return + } + item, err := NewService(h.Orm).AddMember(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) CreateSchedule(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班") + return + } + var input ScheduleInput + h.bind(c, &input) + if h.Errors != nil { + return + } + item, err := NewService(h.Orm).CreateSchedule(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) Publish(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可发布排班"), "仅管理员可发布排班") + return + } + item, err := NewService(h.Orm).Publish(c.Request.Context(), c.Param("id"), user.GetUserId(c)) + h.result(item, err) +} +func (h Handler) CreateOverride(c *gin.Context) { + if !admin(c) { + h.MakeContext(c).Error(403, errors.New("仅管理员可维护替班"), "仅管理员可维护替班") + return + } + var input OverrideInput + h.bind(c, &input) + if h.Errors != nil { + return + } + item, err := NewService(h.Orm).CreateOverride(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.result(item, err) +} +func (h *Handler) bind(c *gin.Context, value any) { + h.MakeContext(c).MakeOrm().Bind(value, binding.JSON) + if h.Errors != nil { + h.Error(400, ErrInvalid, ErrInvalid.Error()) + } +} +func (h Handler) result(item any, err error) { + switch { + case err == nil: + h.OK(item, "保存成功") + case errors.Is(err, ErrInvalid) || errors.Is(err, ErrCoverage): + h.Error(400, err, err.Error()) + case errors.Is(err, ErrNotFound): + h.Error(404, err, err.Error()) + case errors.Is(err, ErrConflict): + h.Error(409, err, err.Error()) + default: + h.Logger.Errorf("write Bell duty schedule failed: %v", err) + h.Error(409, errors.New("排班保存失败"), "排班保存失败") + } +} +func admin(c *gin.Context) bool { + claims := jwt.ExtractClaims(c) + role, _ := claims[jwt.RoleKey].(string) + return role == "admin" +} diff --git a/Bell/server/app/bell/duty_schedule/model.go b/Bell/server/app/bell/duty_schedule/model.go new file mode 100644 index 0000000..4448b00 --- /dev/null +++ b/Bell/server/app/bell/duty_schedule/model.go @@ -0,0 +1,94 @@ +package duty_schedule + +import ( + "encoding/json" + "time" +) + +type Group struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex"` + Timezone string `json:"timezone" gorm:"size:64;not null"` + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + Version int `json:"version" gorm:"not null;default:1"` + CreatedBy int `json:"createdBy" gorm:"not null"` + UpdatedBy int `json:"updatedBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"` +} + +func (Group) TableName() string { return "bell_duty_groups" } + +type Member struct { + GroupID string `json:"groupId" gorm:"type:uuid;primaryKey"` + ContactID string `json:"contactId" gorm:"type:uuid;primaryKey"` + Role string `json:"role" gorm:"size:16;not null"` + CreatedBy int `json:"createdBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` +} + +func (Member) TableName() string { return "bell_duty_members" } + +type ScheduleVersion struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + GroupID string `json:"groupId" gorm:"type:uuid;not null;index"` + Version int `json:"version" gorm:"not null"` + Timezone string `json:"timezone" gorm:"size:64;not null"` + EffectiveFrom time.Time `json:"effectiveFrom" gorm:"type:timestamptz;not null"` + EffectiveTo *time.Time `json:"effectiveTo,omitempty" gorm:"type:timestamptz"` + Status string `json:"status" gorm:"size:16;not null"` + CreatedBy int `json:"createdBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` + PublishedBy *int `json:"publishedBy,omitempty"` + PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz"` +} + +func (ScheduleVersion) TableName() string { return "bell_duty_schedule_versions" } + +type RotationSlot struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + ScheduleVersionID string `json:"scheduleVersionId" gorm:"type:uuid;not null;index"` + Weekday int `json:"weekday" gorm:"not null"` + StartMinute int `json:"startMinute" gorm:"not null"` + EndMinute int `json:"endMinute" gorm:"not null"` + PrimaryContactID string `json:"primaryContactId" gorm:"type:uuid;not null"` + BackupContactID string `json:"backupContactId" gorm:"type:uuid;not null"` +} + +func (RotationSlot) TableName() string { return "bell_duty_rotation_slots" } + +type Override struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + GroupID string `json:"groupId" gorm:"type:uuid;not null;index"` + OriginalContactID string `json:"originalContactId" gorm:"type:uuid;not null"` + ReplacementContactID string `json:"replacementContactId" gorm:"type:uuid;not null"` + StartsAt time.Time `json:"startsAt" gorm:"type:timestamptz;not null;index"` + EndsAt time.Time `json:"endsAt" gorm:"type:timestamptz;not null"` + Reason string `json:"reason" gorm:"size:256;not null"` + CreatedBy int `json:"createdBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` +} + +func (Override) TableName() string { return "bell_duty_overrides" } + +type AuditFact struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + GroupID string `json:"groupId" gorm:"type:uuid;not null;index"` + Action string `json:"action" gorm:"size:32;not null"` + Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"` + ActorID int `json:"actorId" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` +} + +func (AuditFact) TableName() string { return "bell_duty_audit_facts" } + +type GroupView struct { + Group + Members []Member `json:"members"` + Schedules []ScheduleView `json:"schedules"` + Overrides []Override `json:"overrides"` +} +type ScheduleView struct { + ScheduleVersion + Slots []RotationSlot `json:"slots"` +} diff --git a/Bell/server/app/bell/duty_schedule/service.go b/Bell/server/app/bell/duty_schedule/service.go new file mode 100644 index 0000000..48c2f09 --- /dev/null +++ b/Bell/server/app/bell/duty_schedule/service.go @@ -0,0 +1,299 @@ +package duty_schedule + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "go-admin/app/bell/contact" +) + +type PageQuery struct { + PageIndex int `form:"pageIndex"` + PageSize int `form:"pageSize"` + Name string `form:"name"` + Enabled *bool `form:"enabled"` +} +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) Service { return Service{DB: db} } +func (s Service) List(ctx context.Context, q PageQuery) ([]GroupView, int64, error) { + p, z := pageValues(q.PageIndex, q.PageSize) + db := s.DB.WithContext(ctx).Model(&Group{}) + if name := strings.TrimSpace(q.Name); name != "" { + db = db.Where("name ILIKE ?", "%"+name+"%") + } + if q.Enabled != nil { + db = db.Where("enabled = ?", *q.Enabled) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return nil, 0, err + } + var groups []Group + if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil { + return nil, 0, err + } + views := make([]GroupView, 0, len(groups)) + for _, g := range groups { + v, err := s.view(ctx, g) + if err != nil { + return nil, 0, err + } + views = append(views, v) + } + return views, count, nil +} +func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) { + input, err := normalizeGroup(input) + if err != nil { + return GroupView{}, err + } + now := time.Now().UTC() + item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now} + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&item).Error; err != nil { + return err + } + return audit(tx, item.ID, "group_created", item, actor) + }) + return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err +} +func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) { + input, err := normalizeGroup(input) + if err != nil { + return GroupView{}, err + } + if input.ExpectedVersion < 1 { + return GroupView{}, ErrInvalid + } + var item Group + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()}) + if r.Error != nil { + return r.Error + } + if r.RowsAffected != 1 { + return ErrConflict + } + if err := tx.First(&item, "id=?", id).Error; err != nil { + return err + } + return audit(tx, id, "group_updated", item, actor) + }) + if err != nil { + return GroupView{}, err + } + return s.view(ctx, item) +} +func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) { + input.Role = strings.ToLower(strings.TrimSpace(input.Role)) + if input.Role != "primary" && input.Role != "backup" { + return Member{}, ErrInvalid + } + now := time.Now().UTC() + item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now} + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := assertEnabledContact(tx, input.ContactID); err != nil { + return err + } + if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil { + return ErrNotFound + } + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil { + return err + } + return audit(tx, groupID, "member_saved", item, actor) + }) + return item, err +} +func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) { + if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) { + return ScheduleView{}, ErrInvalid + } + if err := validateSlots(input.Slots); err != nil { + return ScheduleView{}, err + } + var result ScheduleView + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var group Group + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil { + return ErrNotFound + } + for _, slot := range input.Slots { + if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil { + return err + } + if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil { + return err + } + } + var latest int + tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest) + now := time.Now().UTC() + version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now} + if err := tx.Create(&version).Error; err != nil { + return err + } + slots := make([]RotationSlot, 0, len(input.Slots)) + for _, in := range input.Slots { + slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID}) + } + if err := tx.Create(&slots).Error; err != nil { + return err + } + if err := audit(tx, groupID, "schedule_created", version, actor); err != nil { + return err + } + result = ScheduleView{ScheduleVersion: version, Slots: slots} + return nil + }) + return result, err +} +func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) { + var result ScheduleView + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item ScheduleVersion + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil { + return ErrNotFound + } + if item.Status != "draft" { + return ErrConflict + } + var slots []RotationSlot + if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil { + return err + } + inputs := make([]SlotInput, 0, len(slots)) + for _, v := range slots { + inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID}) + } + if err := validateSlots(inputs); err != nil { + return err + } + for _, v := range slots { + if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil { + return err + } + if err := assertVerifiedContact(tx, v.BackupContactID); err != nil { + return err + } + } + now := time.Now().UTC() + if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil { + return err + } + item.Status = "published" + item.PublishedBy = &actor + item.PublishedAt = &now + if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil { + return err + } + result = ScheduleView{ScheduleVersion: item, Slots: slots} + return nil + }) + return result, err +} +func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) { + input, err := normalizeOverride(input) + if err != nil { + return Override{}, err + } + item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()} + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil { + return err + } + if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil { + return err + } + var overlaps int64 + if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil { + return err + } + if overlaps > 0 { + return ErrConflict + } + if err := tx.Create(&item).Error; err != nil { + return err + } + return audit(tx, groupID, "override_created", item, actor) + }) + return item, err +} +func (s Service) view(ctx context.Context, g Group) (GroupView, error) { + v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}} + if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil { + return v, err + } + var versions []ScheduleVersion + if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil { + return v, err + } + for _, sv := range versions { + var slots []RotationSlot + if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil { + return v, err + } + v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots}) + } + if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil { + return v, err + } + return v, nil +} +func assertEnabledContact(tx *gorm.DB, id string) error { + var c contact.Contact + if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil { + return ErrInvalid + } + return nil +} +func assertGroupMember(tx *gorm.DB, groupID, contactID string) error { + if err := assertEnabledContact(tx, contactID); err != nil { + return err + } + var count int64 + if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil { + return err + } + if count != 1 { + return ErrInvalid + } + return nil +} +func assertVerifiedContact(tx *gorm.DB, contactID string) error { + if err := assertEnabledContact(tx, contactID); err != nil { + return err + } + var count int64 + err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error + if err != nil { + return err + } + if count == 0 { + return ErrInvalid + } + return nil +} +func audit(tx *gorm.DB, groupID, action string, value any, actor int) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error +} +func pageValues(p, s int) (int, int) { + if p < 1 { + p = 1 + } + if s < 1 || s > 100 { + s = 20 + } + return p, s +} diff --git a/Bell/server/app/bell/duty_schedule/validation.go b/Bell/server/app/bell/duty_schedule/validation.go new file mode 100644 index 0000000..b7bcb56 --- /dev/null +++ b/Bell/server/app/bell/duty_schedule/validation.go @@ -0,0 +1,90 @@ +package duty_schedule + +import ( + "errors" + "sort" + "strings" + "time" +) + +var ( + ErrInvalid = errors.New("值班排班信息不符合要求") + ErrNotFound = errors.New("值班组或排班不存在") + ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试") + ErrCoverage = errors.New("周排班存在空档或重叠") +) + +type GroupInput struct { + Name string `json:"name"` + Timezone string `json:"timezone"` + ExpectedVersion int `json:"expectedVersion"` +} +type MemberInput struct { + ContactID string `json:"contactId"` + Role string `json:"role"` +} +type SlotInput struct { + Weekday int `json:"weekday"` + StartMinute int `json:"startMinute"` + EndMinute int `json:"endMinute"` + PrimaryContactID string `json:"primaryContactId"` + BackupContactID string `json:"backupContactId"` +} +type ScheduleInput struct { + EffectiveFrom time.Time `json:"effectiveFrom"` + EffectiveTo *time.Time `json:"effectiveTo"` + Slots []SlotInput `json:"slots"` +} +type OverrideInput struct { + OriginalContactID string `json:"originalContactId"` + ReplacementContactID string `json:"replacementContactId"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Reason string `json:"reason"` +} + +func normalizeGroup(input GroupInput) (GroupInput, error) { + input.Name = strings.TrimSpace(input.Name) + input.Timezone = strings.TrimSpace(input.Timezone) + if input.Name == "" || len([]rune(input.Name)) > 128 { + return GroupInput{}, ErrInvalid + } + if _, err := time.LoadLocation(input.Timezone); err != nil { + return GroupInput{}, ErrInvalid + } + return input, nil +} +func validateSlots(slots []SlotInput) error { + if len(slots) == 0 { + return ErrCoverage + } + byDay := map[int][]SlotInput{} + for _, slot := range slots { + if slot.Weekday < 0 || slot.Weekday > 6 || slot.StartMinute < 0 || slot.EndMinute > 1440 || slot.StartMinute >= slot.EndMinute || slot.PrimaryContactID == "" || slot.BackupContactID == "" || slot.PrimaryContactID == slot.BackupContactID { + return ErrInvalid + } + byDay[slot.Weekday] = append(byDay[slot.Weekday], slot) + } + for day := 0; day < 7; day++ { + daySlots := byDay[day] + sort.Slice(daySlots, func(i, j int) bool { return daySlots[i].StartMinute < daySlots[j].StartMinute }) + cursor := 0 + for _, slot := range daySlots { + if slot.StartMinute != cursor { + return ErrCoverage + } + cursor = slot.EndMinute + } + if cursor != 1440 { + return ErrCoverage + } + } + return nil +} +func normalizeOverride(input OverrideInput) (OverrideInput, error) { + input.Reason = strings.TrimSpace(input.Reason) + if input.OriginalContactID == "" || input.ReplacementContactID == "" || input.OriginalContactID == input.ReplacementContactID || input.StartsAt.IsZero() || !input.EndsAt.After(input.StartsAt) || input.Reason == "" || len([]rune(input.Reason)) > 256 { + return OverrideInput{}, ErrInvalid + } + return input, nil +} diff --git a/Bell/server/app/bell/router/contact.go b/Bell/server/app/bell/router/contact.go new file mode 100644 index 0000000..fe95cdc --- /dev/null +++ b/Bell/server/app/bell/router/contact.go @@ -0,0 +1,21 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "go-admin/app/bell/contact" + "go-admin/common/middleware" +) + +func init() { registrars = append(registrars, registerContactRouter) } +func registerContactRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + h := contact.Handler{} + secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + secured.GET("/contacts", h.List) + secured.POST("/contacts", h.Create) + secured.PUT("/contacts/:id", h.Update) + secured.PUT("/contacts/:id/enabled", h.SetEnabled) + secured.POST("/contacts/:id/channels", h.AddChannel) + secured.POST("/contact-channels/:id/validations", h.ValidateChannel) +} diff --git a/Bell/server/app/bell/router/duty_schedule.go b/Bell/server/app/bell/router/duty_schedule.go new file mode 100644 index 0000000..ade411e --- /dev/null +++ b/Bell/server/app/bell/router/duty_schedule.go @@ -0,0 +1,22 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "go-admin/app/bell/duty_schedule" + "go-admin/common/middleware" +) + +func init() { registrars = append(registrars, registerDutyScheduleRouter) } +func registerDutyScheduleRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + h := duty_schedule.Handler{} + secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + secured.GET("/duty-groups", h.List) + secured.POST("/duty-groups", h.CreateGroup) + secured.PUT("/duty-groups/:id", h.UpdateGroup) + secured.POST("/duty-groups/:id/members", h.AddMember) + secured.POST("/duty-groups/:id/schedules", h.CreateSchedule) + secured.POST("/duty-schedules/:id/publish", h.Publish) + secured.POST("/duty-groups/:id/overrides", h.CreateOverride) +} diff --git a/Bell/server/cmd/api/server.go b/Bell/server/cmd/api/server.go index 201c0c2..6f7acf3 100644 --- a/Bell/server/cmd/api/server.go +++ b/Bell/server/cmd/api/server.go @@ -21,6 +21,7 @@ import ( "go-admin/app/admin/models" "go-admin/app/admin/router" "go-admin/app/bell/alert_lifecycle" + "go-admin/app/bell/contact" bellrouter "go-admin/app/bell/router" "go-admin/app/bell/synthetic" "go-admin/common/bellconfig" @@ -184,7 +185,8 @@ func initRouter() { Use(common.RequestId(pkg.TrafficKey)). Use(api.SetRequestLogger). Use(synthetic.RedactRequestBody()). - Use(alert_lifecycle.RedactRequestBody()) + Use(alert_lifecycle.RedactRequestBody()). + Use(contact.RedactRequestBody()) common.InitMiddleware(r) diff --git a/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule.go b/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule.go new file mode 100644 index 0000000..b544816 --- /dev/null +++ b/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule.go @@ -0,0 +1,160 @@ +package version + +import ( + "fmt" + "runtime" + + "go-admin/app/bell/contact" + duty "go-admin/app/bell/duty_schedule" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellContactSchedule) +} +func migrateBellContactSchedule(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&contact.Contact{}, &contact.Channel{}, &contact.ChannelValidation{}, &contact.AuditFact{}, &duty.Group{}, &duty.Member{}, &duty.ScheduleVersion{}, &duty.RotationSlot{}, &duty.Override{}, &duty.AuditFact{}); err != nil { + return err + } + if tx.Dialector.Name() == "postgres" { + for _, sql := range contactScheduleSQL { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + if err := seedContactScheduleAccess(tx); err != nil { + return err + } + } + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error + }) +} + +var contactScheduleSQL = []string{ + `ALTER TABLE bell_contacts ADD CONSTRAINT bell_contacts_version_check CHECK (version > 0)`, + `ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_kind_check CHECK (kind IN ('sms','voice'))`, + `ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `CREATE UNIQUE INDEX bell_contact_channel_identity_idx ON bell_contact_channels(contact_id,kind,address_fingerprint)`, + `CREATE TRIGGER bell_contact_channels_immutable BEFORE UPDATE OR DELETE ON bell_contact_channels FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, + `ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_status_check CHECK (status IN ('verified','failed'))`, + `ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_channel_fk FOREIGN KEY (channel_id) REFERENCES bell_contact_channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_contact_audit_facts ADD CONSTRAINT bell_contact_audit_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_groups ADD CONSTRAINT bell_duty_groups_version_check CHECK (version > 0)`, + `ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_role_check CHECK (role IN ('primary','backup'))`, + `ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `CREATE UNIQUE INDEX bell_duty_schedule_group_version_idx ON bell_duty_schedule_versions(group_id,version)`, + `ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_status_check CHECK (status IN ('draft','published'))`, + `ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `CREATE OR REPLACE FUNCTION bell_guard_schedule_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' OR OLD.status = 'published' THEN RAISE EXCEPTION 'Bell published schedule cannot be changed' USING ERRCODE = '55000'; END IF; IF NEW.status <> 'published' OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Bell schedule transition is invalid' USING ERRCODE = '55000'; END IF; RETURN NEW; END $$`, + `CREATE TRIGGER bell_duty_schedule_version_guard BEFORE UPDATE OR DELETE ON bell_duty_schedule_versions FOR EACH ROW EXECUTE FUNCTION bell_guard_schedule_version()`, + `ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_range_check CHECK (weekday BETWEEN 0 AND 6 AND start_minute >= 0 AND end_minute <= 1440 AND start_minute < end_minute AND primary_contact_id <> backup_contact_id)`, + `ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_version_fk FOREIGN KEY (schedule_version_id) REFERENCES bell_duty_schedule_versions(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_primary_fk FOREIGN KEY (primary_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_backup_fk FOREIGN KEY (backup_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_range_check CHECK (starts_at < ends_at AND original_contact_id <> replacement_contact_id)`, + `ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_original_fk FOREIGN KEY (original_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_replacement_fk FOREIGN KEY (replacement_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `ALTER TABLE bell_duty_audit_facts ADD CONSTRAINT bell_duty_audit_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`, + `CREATE TRIGGER bell_contact_validations_immutable BEFORE UPDATE OR DELETE ON bell_contact_channel_validations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, + `CREATE TRIGGER bell_contact_audit_immutable BEFORE UPDATE OR DELETE ON bell_contact_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, + `CREATE TRIGGER bell_duty_slots_immutable BEFORE UPDATE OR DELETE ON bell_duty_rotation_slots FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, + `CREATE TRIGGER bell_duty_overrides_immutable BEFORE UPDATE OR DELETE ON bell_duty_overrides FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, + `CREATE TRIGGER bell_duty_audit_immutable BEFORE UPDATE OR DELETE ON bell_duty_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`, +} + +type contactScheduleSeed struct { + ID int + Path string + Action string +} + +func seedContactScheduleAccess(tx *gorm.DB) error { + if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'),GREATEST((SELECT max(menu_id) FROM sys_menu),1));SELECT setval(pg_get_serial_sequence('sys_api','id'),GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil { + return err + } + var rootID int + if err := tx.Raw("SELECT menu_id FROM sys_menu WHERE path='/bell' AND parent_id=0 ORDER BY menu_id LIMIT 1").Scan(&rootID).Error; err != nil || rootID == 0 { + return fmt.Errorf("Bell menu root missing") + } + contacts, err := insertContactScheduleMenu(tx, rootID, "BellContacts", "联系人与通道", "user", "contacts", "C", "bell:contact:list", "", "/bell/contacts/index", 4) + if err != nil { + return err + } + dutyMenu, err := insertContactScheduleMenu(tx, rootID, "BellDutySchedules", "值班排班", "time", "duty-schedules", "C", "bell:duty:list", "", "/bell/duty-schedules/index", 5) + if err != nil { + return err + } + contactWrite, err := insertContactScheduleMenu(tx, contacts.ID, "", "维护联系人", "", "", "F", "bell:contact:write", "POST", "", 1) + if err != nil { + return err + } + dutyWrite, err := insertContactScheduleMenu(tx, dutyMenu.ID, "", "维护排班", "", "", "F", "bell:duty:write", "POST", "", 1) + if err != nil { + return err + } + specs := []struct{ title, path, action string }{ + {"联系人列表", "/api/v1/bell/contacts", "GET"}, {"新增联系人", "/api/v1/bell/contacts", "POST"}, {"修改联系人", "/api/v1/bell/contacts/:id", "PUT"}, {"启停联系人", "/api/v1/bell/contacts/:id/enabled", "PUT"}, {"新增联系通道", "/api/v1/bell/contacts/:id/channels", "POST"}, {"记录通道验证", "/api/v1/bell/contact-channels/:id/validations", "POST"}, + {"值班组列表", "/api/v1/bell/duty-groups", "GET"}, {"新增值班组", "/api/v1/bell/duty-groups", "POST"}, {"修改值班组", "/api/v1/bell/duty-groups/:id", "PUT"}, {"保存值班成员", "/api/v1/bell/duty-groups/:id/members", "POST"}, {"新增排班版本", "/api/v1/bell/duty-groups/:id/schedules", "POST"}, {"发布排班版本", "/api/v1/bell/duty-schedules/:id/publish", "POST"}, {"新增临时替班", "/api/v1/bell/duty-groups/:id/overrides", "POST"}, + } + apis := make([]contactScheduleSeed, 0, len(specs)) + for _, s := range specs { + v, e := insertContactScheduleAPI(tx, s.title, s.path, s.action) + if e != nil { + return e + } + apis = append(apis, v) + } + links := map[int][]contactScheduleSeed{contacts.ID: {apis[0]}, contactWrite.ID: apis[1:6], dutyMenu.ID: {apis[6]}, dutyWrite.ID: apis[7:]} + for menu, items := range links { + for _, item := range items { + if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", menu, item.ID).Error; err != nil { + return err + } + } + } + var operator int + if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key='operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operator).Error; err != nil { + return err + } + if operator != 0 { + for _, menu := range []contactScheduleSeed{contacts, dutyMenu} { + if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operator, menu.ID).Error; err != nil { + return err + } + } + for _, item := range []contactScheduleSeed{apis[0], apis[6]} { + if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil { + return err + } + } + } + return nil +} +func insertContactScheduleMenu(tx *gorm.DB, parent int, name, title, icon, path, menuType, permission, action, component string, sort int) (contactScheduleSeed, error) { + var id int + err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at) VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`, name, title, icon, path, menuType, action, permission, parent, component, sort).Scan(&id).Error + if err != nil { + return contactScheduleSeed{}, err + } + var parentPaths string + if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id=?", parent).Scan(&parentPaths).Error; err != nil { + return contactScheduleSeed{}, err + } + if err = tx.Exec("UPDATE sys_menu SET paths=? WHERE menu_id=?", fmt.Sprintf("%s/%d", parentPaths, id), id).Error; err != nil { + return contactScheduleSeed{}, err + } + return contactScheduleSeed{ID: id}, nil +} +func insertContactScheduleAPI(tx *gorm.DB, title, path, action string) (contactScheduleSeed, error) { + var id int + err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by) VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error + return contactScheduleSeed{ID: id, Path: path, Action: action}, err +} diff --git a/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule_test.go b/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule_test.go new file mode 100644 index 0000000..0847bba --- /dev/null +++ b/Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule_test.go @@ -0,0 +1,36 @@ +package version + +import ( + "testing" + + "go-admin/app/bell/contact" + duty "go-admin/app/bell/duty_schedule" + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestBellContactScheduleMigrationIsIdempotent(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err = migrateBellContactSchedule(db, "2026090110000"); err != nil { + t.Fatalf("attempt %d: %v", i+1, err) + } + } + for name, model := range map[string]any{"contacts": &contact.Contact{}, "channels": &contact.Channel{}, "validations": &contact.ChannelValidation{}, "groups": &duty.Group{}, "members": &duty.Member{}, "versions": &duty.ScheduleVersion{}, "slots": &duty.RotationSlot{}, "overrides": &duty.Override{}} { + if !db.Migrator().HasTable(model) { + t.Fatalf("%s table missing", name) + } + } + var count int64 + if err = db.Model(&common.Migration{}).Where("version=?", "2026090110000").Count(&count).Error; err != nil || count != 1 { + t.Fatalf("migration records=%d err=%v", count, err) + } +} diff --git a/Bell/server/tests/bell_contact_schedule/integration_test.go b/Bell/server/tests/bell_contact_schedule/integration_test.go new file mode 100644 index 0000000..5397752 --- /dev/null +++ b/Bell/server/tests/bell_contact_schedule/integration_test.go @@ -0,0 +1,169 @@ +package bell_contact_schedule_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + adminmodels "go-admin/app/admin/models" + "go-admin/app/bell/contact" + duty "go-admin/app/bell/duty_schedule" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestContactSchedulePostgres(t *testing.T) { + dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + key := []byte("0123456789abcdef0123456789abcdef") + contacts := contact.NewService(db, key) + + primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1) + if err != nil { + t.Fatal(err) + } + backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1) + if err != nil { + t.Fatal(err) + } + primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1) + if err != nil { + t.Fatal(err) + } + backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1) + if err != nil { + t.Fatal(err) + } + if strings.Contains(primaryChannel.AddressMasked, "13800000001") { + t.Fatal("channel response leaked address") + } + plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID) + if err != nil || plain != "+8613800000001" { + t.Fatalf("server-only decrypt failed: %q %v", plain, err) + } + encoded, _ := json.Marshal(primaryChannel) + if strings.Contains(string(encoded), plain) { + t.Fatal("serialized channel leaked plaintext") + } + if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil { + t.Fatal(err) + } + if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil { + t.Fatal(err) + } + if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) { + t.Fatalf("stale contact update err=%v", err) + } + if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil { + t.Fatal(err) + } + listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20}) + if err != nil || len(listed) != 2 { + t.Fatalf("contact list len=%d err=%v", len(listed), err) + } + var primaryView *contact.ContactView + for index := range listed { + if listed[index].ID == primary.ID { + primaryView = &listed[index] + } + } + if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" { + t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView) + } + // Re-enable with the new version before assigning duty. + var disabled contact.Contact + if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil { + t.Fatal(err) + } + if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil { + t.Fatal(err) + } + + duties := duty.NewService(db) + group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1) + if err != nil { + t.Fatal(err) + } + if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil { + t.Fatal(err) + } + if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil { + t.Fatal(err) + } + if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) { + t.Fatalf("schedule gap was accepted: %v", err) + } + slots := make([]duty.SlotInput, 0, 7) + for day := 0; day < 7; day++ { + slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID}) + } + v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1) + if err != nil { + t.Fatal(err) + } + published, err := duties.Publish(ctx, v1.ID, 1) + if err != nil || published.Status != "published" { + t.Fatalf("publish status=%s err=%v", published.Status, err) + } + v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1) + if err != nil || v2.Version != 2 { + t.Fatalf("second version=%d err=%v", v2.Version, err) + } + var persisted duty.ScheduleVersion + if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" { + t.Fatalf("historical version changed: %#v err=%v", persisted, err) + } + now := time.Now().UTC().Add(2 * time.Hour) + if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil { + t.Fatal(err) + } + if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) { + t.Fatalf("overlap err=%v", err) + } + + if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil { + t.Fatal("validation fact was mutable") + } + if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil { + t.Fatal("published rotation slot was mutable") + } + if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil { + t.Fatal("published schedule version was mutable") + } + var menus, reads, writes int64 + if err = db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id=rm.role_id").Joins("JOIN sys_menu m ON m.menu_id=rm.menu_id").Where("r.role_key=? AND m.path IN ?", "operator", []string{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil { + t.Fatal(err) + } + if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil { + t.Fatal(err) + } + if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil { + t.Fatal(err) + } + if menus != 2 || reads != 2 || writes != 0 { + t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes) + } + password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD") + if password != "" { + var roleID int + if err = db.Table("sys_role").Select("role_id").Where("role_key=?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("operator role id=%d err=%v", roleID, err) + } + user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"} + if err = db.Create(&user).Error; err != nil { + t.Fatal(err) + } + } +} diff --git a/Bell/server/tests/bell_contact_schedule/run-postgres.ps1 b/Bell/server/tests/bell_contact_schedule/run-postgres.ps1 new file mode 100644 index 0000000..f2185f4 --- /dev/null +++ b/Bell/server/tests/bell_contact_schedule/run-postgres.ps1 @@ -0,0 +1,124 @@ +[CmdletBinding()] +param([string]$PostgresBin = 'D:\pgsql17\bin') + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +$pgStarted = $false +$server = $null +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-183-' + [guid]::NewGuid().ToString('N')) +$data = Join-Path $testRoot 'postgres' +$log = Join-Path $testRoot 'postgres.log' +$pgOut = Join-Path $testRoot 'pg.out' +$pgErr = Join-Path $testRoot 'pg.err' +$serverOut = Join-Path $testRoot 'bell.out.log' +$serverErr = Join-Path $testRoot 'bell.err.log' +$serverExe = Join-Path $testRoot 'bell-server.exe' +$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Get-FreePort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } +} +function Wait-Port([int]$Port) { + for ($attempt = 0; $attempt -lt 120; $attempt++) { + try { + $client = [Net.Sockets.TcpClient]::new() + $open = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected + $client.Dispose() + if ($open) { return } + } catch {} + Start-Sleep -Milliseconds 250 + } + throw "PostgreSQL port $Port did not open" +} +function Wait-Health([string]$BaseUrl) { + for ($attempt = 0; $attempt -lt 100; $attempt++) { + try { + $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy + if ($health.status -eq 'ok') { return } + } catch {} + Start-Sleep -Milliseconds 300 + } + throw 'Bell health endpoint did not become ready' +} + +New-Item -ItemType Directory -Path $testRoot | Out-Null +$pgPort = Get-FreePort +$bellPort = Get-FreePort +$baseUrl = "http://127.0.0.1:$bellPort" +try { + foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) { + if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name) -PathType Leaf)) { throw "Missing PostgreSQL tool: $name" } + } + & (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'initdb failed' } + $arguments = "-D `"$data`" -l `"$log`" -o `"-p $pgPort -h 127.0.0.1`" start" + Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -RedirectStandardOutput $pgOut -RedirectStandardError $pgErr -WindowStyle Hidden | Out-Null + Wait-Port $pgPort + $pgStarted = $true + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres bell_183 + if ($LASTEXITCODE -ne 0) { throw 'createdb failed' } + + $env:GOTOOLCHAIN = 'go1.26.5' + $env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=bell_183 sslmode=disable" + $env:BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL = $env:BELL_DATABASE_URL + $env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') + $env:BELL_BOOTSTRAP_USERNAME = 'bell_183_admin' + $env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef0123456789abcdef')) + $env:BELL_HOST = '127.0.0.1' + $env:BELL_PORT = $bellPort.ToString() + + Push-Location $serverRoot + try { + go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log') + if ($LASTEXITCODE -ne 0) { throw "migration failed: $(Join-Path $testRoot 'migrate.log')" } + go test ./tests/bell_contact_schedule -count=1 -v + if ($LASTEXITCODE -ne 0) { throw 'contact schedule tests failed' } + go build -o $serverExe . + if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' } + } finally { Pop-Location } + + $server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru + Wait-Health $baseUrl + $adminBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress + $admin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminBody -NoProxy + $adminHeaders = @{ Authorization = "Bearer $($admin.token)" } + $contactBody = @{ name = 'HTTP联系人'; role = '测试值班' } | ConvertTo-Json -Compress + $created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $contactBody -NoProxy + if ([int]$created.code -ne 200) { throw 'administrator contact create failed' } + $address = '+8613900000003' + $channelBody = @{ kind = 'sms'; address = $address } | ConvertTo-Json -Compress + $channel = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts/$($created.data.id)/channels" -Headers $adminHeaders -ContentType 'application/json' -Body $channelBody -NoProxy + if ([int]$channel.code -ne 200 -or ($channel | ConvertTo-Json -Depth 10 -Compress).Contains($address)) { throw 'write-only channel HTTP boundary failed' } + $logRow = '' + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $logRow = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $pgPort -U postgres -d bell_183 -Atc "SELECT id::text || '|' || coalesce(oper_param,'') FROM sys_opera_log WHERE oper_url LIKE '/api/v1/bell/contacts/%/channels' ORDER BY id DESC LIMIT 1" + if ($LASTEXITCODE -ne 0) { throw 'operation log query failed' } + if ($logRow) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $logRow -or $logRow.Contains($address)) { throw "operation log redaction failed: $logRow" } + $loggedBody = ($logRow -split '\|', 2)[1] + if ($loggedBody -and -not $loggedBody.Contains('"redacted":true')) { throw "unexpected operation log marker: $loggedBody" } + + $operatorBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress + $operator = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorBody -NoProxy + $operatorHeaders = @{ Authorization = "Bearer $($operator.token)" } + foreach ($path in @('/api/v1/bell/contacts', '/api/v1/bell/duty-groups')) { + $read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -NoProxy + if ([int]$read.code -ne 200) { throw "operator read failed: $path" } + } + $denied = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $operatorHeaders -ContentType 'application/json' -Body $contactBody -NoProxy + if ([int]$denied.code -ne 403) { throw "operator write returned $($denied.code)" } + $menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -NoProxy + $menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress + foreach ($title in @('联系人与通道', '值班排班')) { if (-not $menuJson.Contains($title)) { throw "operator menu missing $title" } } + Write-Output 'BELL_183_HTTP admin_contact=200 channel_write_only=true operator_reads=200 operator_write=403 menus=true' +} finally { + if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000) | Out-Null } + if ($pgStarted) { & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $testRoot 'stop.log') } + foreach ($name in @('BELL_DATABASE_URL', 'BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_RULE_ALERT_OPERATOR_PASSWORD', 'BELL_CONTACT_CHANNEL_KEY', 'BELL_HOST', 'BELL_PORT')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } + Write-Verbose "Bell #183 artifacts: $testRoot" +} diff --git a/Bell/server/tests/bell_contact_schedule/validation_test.go b/Bell/server/tests/bell_contact_schedule/validation_test.go new file mode 100644 index 0000000..407b3f3 --- /dev/null +++ b/Bell/server/tests/bell_contact_schedule/validation_test.go @@ -0,0 +1,22 @@ +package bell_contact_schedule_test + +import ( + "encoding/base64" + "errors" + "testing" + + "go-admin/app/bell/contact" +) + +func TestChannelKeyAndWriteOnlyRoundTrip(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + key, err := contact.ParseChannelKey(encoded) + if err != nil || len(key) != 32 { + t.Fatalf("key parse failed: len=%d err=%v", len(key), err) + } + for _, value := range []string{"", "short", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))} { + if _, err = contact.ParseChannelKey(value); !errors.Is(err, contact.ErrChannelKeyUnavailable) { + t.Fatalf("invalid key accepted: %q err=%v", value, err) + } + } +} diff --git a/Bell/ui/src/api/bell/contact.js b/Bell/ui/src/api/bell/contact.js new file mode 100644 index 0000000..d42bd7f --- /dev/null +++ b/Bell/ui/src/api/bell/contact.js @@ -0,0 +1,7 @@ +import request from '@/utils/request' +export function listContacts(query) { return request({ url: '/api/v1/bell/contacts', method: 'get', params: query }) } +export function createContact(data) { return request({ url: '/api/v1/bell/contacts', method: 'post', data }) } +export function updateContact(id, data) { return request({ url: `/api/v1/bell/contacts/${id}`, method: 'put', data }) } +export function setContactEnabled(id, enabled, expectedVersion) { return request({ url: `/api/v1/bell/contacts/${id}/enabled`, method: 'put', data: { enabled, expectedVersion }}) } +export function addContactChannel(id, data) { return request({ url: `/api/v1/bell/contacts/${id}/channels`, method: 'post', data }) } +export function validateContactChannel(id, data) { return request({ url: `/api/v1/bell/contact-channels/${id}/validations`, method: 'post', data }) } diff --git a/Bell/ui/src/api/bell/duty-schedule.js b/Bell/ui/src/api/bell/duty-schedule.js new file mode 100644 index 0000000..b98b6ee --- /dev/null +++ b/Bell/ui/src/api/bell/duty-schedule.js @@ -0,0 +1,8 @@ +import request from '@/utils/request' +export function listDutyGroups(query) { return request({ url: '/api/v1/bell/duty-groups', method: 'get', params: query }) } +export function createDutyGroup(data) { return request({ url: '/api/v1/bell/duty-groups', method: 'post', data }) } +export function updateDutyGroup(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}`, method: 'put', data }) } +export function saveDutyMember(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/members`, method: 'post', data }) } +export function createDutySchedule(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/schedules`, method: 'post', data }) } +export function publishDutySchedule(id) { return request({ url: `/api/v1/bell/duty-schedules/${id}/publish`, method: 'post' }) } +export function createDutyOverride(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/overrides`, method: 'post', data }) } diff --git a/Bell/ui/src/views/bell/contacts/index.vue b/Bell/ui/src/views/bell/contacts/index.vue new file mode 100644 index 0000000..3ce1640 --- /dev/null +++ b/Bell/ui/src/views/bell/contacts/index.vue @@ -0,0 +1,21 @@ + + + diff --git a/Bell/ui/src/views/bell/duty-schedules/index.vue b/Bell/ui/src/views/bell/duty-schedules/index.vue new file mode 100644 index 0000000..bc04462 --- /dev/null +++ b/Bell/ui/src/views/bell/duty-schedules/index.vue @@ -0,0 +1,23 @@ + + + diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index b3894a1..b093592 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 77af1bb5a71d7b89e74b1905ca11b6040dc0d1eb -synchronized_at: 2026-08-31T08:25:26Z +wiki_revision: 0b760f2398ea4c1fd1d776b0cf869532b167c495 +synchronized_at: 2026-09-01T04:08:05Z # 架构与代码地图 @@ -382,3 +382,20 @@ coordination-common.ps1 协调层不拥有业务数据或契约,不共享用户表、JWT、Cookie、数据库内部模型、摄像头凭据或产品实现。它不替代 `contracts/**`,也不让任一产品成为另一产品的启动前置;connector 可关闭,三端核心能力继续独立运行。根级运行状态位于清单指定的 `runtime_root\state`,产品日志仍归各自日志目录和产品入口管理。 + + +## Bell 联系人与值班排班代码路径 + +后端调用链: + +- `Bell/server/router/contact.go` → `app/bell/contact.Handler` → `app/bell/contact.Service` → 联系人、通道、验证事实与审计表。 +- `Bell/server/router/duty_schedule.go` → `app/bell/duty_schedule.Handler` → `app/bell/duty_schedule.Service` → 值班组、成员、排班版本、时段、发布与临时替班表。 +- `Bell/server/cmd/migrate/migration/version/2026090110000_contact_schedule.go` 创建业务表、约束、不可变事实触发器,以及 GoAdmin 菜单、API 和 Casbin 权限种子。 +- `Bell/ui/src/api/bell/contact.js`、`duty-schedule.js` 与对应 `views/bell/` 页面复用 GoAdmin 的请求封装、布局、表格、表单、弹窗和权限指令。 + +安全边界: + +- `BELL_CONTACT_CHANNEL_KEY` 是 Base64 编码的 32 字节 AES-256 密钥,只允许从进程环境读取,不得写入仓库或日志。 +- 通道地址采用 AES-GCM 加密,API 模型不暴露密文字段;服务端只在受控发送路径按需解密。 +- `Bell/server/cmd/api/server.go` 在 GoAdmin `LoggerToFile` 之前注册联系人通道请求体脱敏中间件,使操作日志只能接触固定脱敏内容。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7a66b0b..0ba10cf 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.- -wiki_revision: c0903448152d7fa88715e902efa99615a6b97083 -synchronized_at: 2026-08-31T07:23:13Z +wiki_revision: 9be486cd2d3fe424cf389f65c2c868eacab6a020 +synchronized_at: 2026-09-01T04:08:16Z # 业务规则与术语 @@ -287,3 +287,22 @@ synchronized_at: 2026-08-31T07:23:13Z - **停用规则**:关闭 connector 只停止新接入或投递;不得清空未投递 Outbox、持久 replay、Receipt、Event、证据元数据或审计。 - **独立运行**:Sense、Brain、Bell 不因对端未安装、离线或 connector 关闭而停止各自核心能力;不得以共享数据库/JWT/Cookie 规避故障隔离。 + + +## Bell 联系人与排班术语 + +- **联系人(Contact)**:可参与 Bell 告警接收和值班安排的人员记录。 +- **通知通道(Channel)**:联系人使用的消息到达方式;通道地址密文保存,对外只提供脱敏摘要。 +- **通道验证事实(Channel Validation)**:一次独立、只追加的验证结果;“已验证”不等于“已启用”。 +- **值班组(Duty Group)**:参与同一排班规则的一组联系人。 +- **排班版本(Schedule Version)**:值班组在指定 IANA 时区下的一套完整周排班;发布后冻结,不原位修改。 +- **轮值时段(Rotation Slot)**:排班版本内的连续时间段,包含不同的主值班与备值班联系人。 +- **临时替班(Override)**:在已发布排班上的只追加例外事实,不改写原排班历史。 + +规则: + +1. 发布前必须验证整周无空档、无重叠,并验证主备不同、成员有效及通知通道已启用且已验证。 +2. 已发布排班、通道验证事实、审计事实和临时替班记录保持不可变;更正通过新版本或新事实完成。 +3. 同一排班版本中的临时替班不能重叠。 +4. 普通操作员只读;创建、修改、启停、验证、发布和替班由管理员执行。 + diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index e843ec5..5df7396 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.- -wiki_revision: 889ffccc30a998a5cc8c4a5d91af3c72771c093a -synchronized_at: 2026-08-31T13:00:20Z +wiki_revision: e90d408200d7189771eeec35ae76a00837e2eedf +synchronized_at: 2026-09-01T04:08:26Z # 本地开发与验证 @@ -816,3 +816,33 @@ git diff --check 已验证范围不包含真实 GPU/生产模型、真实摄像头、通知供应商、生产迁移、16 路长稳和客户现场效果;这些项目必须在对应环境和独立工单中验收。 + + +## 验证 Bell 联系人与值班排班 + +后端与 PostgreSQL 集成验证: + +```powershell +Set-Location Bell/server +$env:GOTOOLCHAIN = 'go1.26.5' +go test ./... -count=1 +pwsh ./tests/bell_contact_schedule/run-postgres.ps1 +``` + +前端验证: + +```powershell +Set-Location Bell/ui +corepack pnpm lint +corepack pnpm build:prod +``` + +联系人通道加密需要在运行进程中配置 `BELL_CONTACT_CHANNEL_KEY`。它必须是 Base64 编码的 32 字节随机值,不得提交到 Git、Wiki、工单或日志。仅为当前 PowerShell 进程生成测试密钥: + +```powershell +$keyBytes = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32) +$env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String($keyBytes) +``` + +PostgreSQL 脚本会验证密文保存、写接口不回显地址、验证与启用状态分离、过期版本冲突、完整周覆盖、已发布历史不可修改、临时替班不重叠、不可变事实约束、操作员只读权限,以及操作日志不含通道明文。当前范围不连接真实外部消息提供商。 + diff --git a/docs/09-product-requirements.md b/docs/09-product-requirements.md index 653967b..c4ce071 100644 --- a/docs/09-product-requirements.md +++ b/docs/09-product-requirements.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Product-Requirements wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.- -wiki_revision: 2b110cd34439aca8aef4bc509ba8b50655b18c0a -synchronized_at: 2026-08-31T07:24:22Z +wiki_revision: a2aada9db75909dc5fc85cc5d91c8c8bcab82c45 +synchronized_at: 2026-09-01T04:09:23Z # 产品需求 @@ -294,3 +294,17 @@ Brain 将内部匿名候选映射为标准事件;默认拓扑由 Sense 以机 Sense 的 `local_event.CreateWithOutbox` 是本地候选与标准事件 Outbox 的正式原子写入口;当前仓库尚无生产本地候选创建调用链,不把不存在的上游路径声明为已接通。真实客户 PKI、生产 PostgreSQL、现场网络与最终故障隔离由 #154/#155 验证。 + + +## Bell 联系人和值班排班 + +Bell 的告警接收对象和值班安排由工单 #183 建立,目标是让管理员维护可审计、可验证且不泄露通道地址的通知基础数据。 + +- 联系人与通知通道分离管理;通道地址只以密文保存,API、操作日志和页面均不得返回或记录明文,只展示脱敏摘要。 +- 通道“已启用”和“已验证”是两个独立状态;验证结果以只追加事实记录保存,不能用启用状态替代验证。 +- 值班组成员必须是已启用联系人,发布前主值班与备值班必须是不同联系人,且均具有已启用、已验证的通知通道。 +- 周排班使用 IANA 时区,必须覆盖完整一周且不允许时间空档或重叠;排班以版本发布,已发布版本不可修改。 +- 临时替班作为只追加事实记录保存,不能覆盖或改写已发布历史,且同一排班版本内不得时间重叠。 +- 普通操作员只读,管理员可维护与发布。 +- 本范围只提供联系人、通道验证事实和值班排班基础能力,不执行真实外部通知;实际发送属于后续工单 #185。 +