Compare commits

..
Author SHA1 Message Date
ila 5128f080b4 Merge Bell 联系人与值班排班 (#183)
用户已于 2026-09-01 明确验收通过 #183。
2026-09-01 14:14:35 +08:00
QiuSW e81f00e9aa feat: 实现 Bell 联系人与值班排班 (#183) 2026-09-01 12:16:52 +08:00
ila 5adee5c3b4 merge: 同步三项目协调 E2E 文档 (#171)
用户于 2026-08-31 明确验收通过 #171。
2026-08-31 21:42:47 +08:00
QiuSW a69ef627c7 docs: 修正协调 E2E 清理边界措辞 (#171) 2026-08-31 21:12:33 +08:00
QiuSW d4de462d44 docs: 同步协调 E2E 运行与排障说明 (#171) 2026-08-31 21:08:32 +08:00
ila 0276bceab5 merge: 完成三项目协调隔离 E2E (#155)
用户已于 2026-08-31 明确验收通过 #155。
2026-08-31 20:49:20 +08:00
28 changed files with 2052 additions and 13 deletions
+59
View File
@@ -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[:])
}
+146
View File
@@ -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
+68
View File
@@ -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"`
}
+62
View File
@@ -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, "/")
}
+251
View File
@@ -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
}
@@ -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
}
@@ -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"
}
@@ -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"`
}
@@ -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
}
@@ -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
}
+21
View File
@@ -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)
}
@@ -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)
}
+3 -1
View File
@@ -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)
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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"
}
@@ -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)
}
}
}
+7
View File
@@ -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 }) }
+8
View File
@@ -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 }) }
+21
View File
@@ -0,0 +1,21 @@
<template>
<BasicLayout><template #wrapper><el-card>
<template #header><div class="heading"><div><h2>联系人与通道</h2><p>通道地址保存后只显示脱敏值,验证状态与联系人启用状态相互独立。</p></div><el-button v-permisaction="['bell:contact:write']" type="primary" @click="openCreate">新增联系人</el-button></div></template>
<el-form ref="queryForm" :model="query" :inline="true"><el-form-item label="联系人" prop="name"><el-input v-model="query.name" clearable placeholder="姓名或岗位" @keyup.enter="search" /></el-form-item><el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部" style="width:120px"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item><el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state" />
<el-table v-loading="loading" :data="items" border row-key="id">
<el-table-column prop="name" label="联系人" min-width="130" /><el-table-column prop="role" label="岗位" min-width="130" />
<el-table-column label="通道" min-width="260"><template #default="scope"><div v-if="scope.row.channels.length"><el-tag v-for="ch in scope.row.channels" :key="ch.id" :type="statusType(ch.status)" class="channel">{{ kindName(ch.kind) }} {{ ch.addressMasked }} · {{ statusName(ch.status) }}</el-tag></div><span v-else class="muted">未配置</span></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canWrite" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column><el-table-column prop="version" label="版本" width="70" />
<el-table-column label="操作" width="210" fixed="right"><template #default="scope"><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openChannel(scope.row)">新增通道</el-button><el-dropdown v-if="scope.row.channels.length && canWrite" @command="command => validate(scope.row, command)"><el-button link type="primary">验证通道</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="ch in scope.row.channels" :key="ch.id" :command="ch">{{ kindName(ch.kind) }} {{ ch.addressMasked }}</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
<template #empty><el-empty description="暂无联系人" /></template>
</el-table><pagination v-show="total>0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<el-dialog v-model="contactDialog" :title="editing?'编辑联系人':'新增联系人'" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-form ref="contactForm" :model="form" :rules="rules" label-position="top"><el-form-item label="称呼" prop="name"><el-input v-model.trim="form.name" maxlength="128" /></el-form-item><el-form-item label="岗位" prop="role"><el-input v-model.trim="form.role" maxlength="128" /></el-form-item></el-form><template #footer><el-button @click="contactDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveContact">保存</el-button></template></el-dialog>
<el-dialog v-model="channelDialog" title="新增联系通道" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-alert title="号码仅在本次填写时可见,保存后只返回脱敏值。" type="info" :closable="false" class="state" /><el-form ref="channelForm" :model="channel" :rules="channelRules" label-position="top"><el-form-item label="通道" prop="kind"><el-select v-model="channel.kind" style="width:100%"><el-option label="短信" value="sms" /><el-option label="语音" value="voice" /></el-select></el-form-item><el-form-item label="号码" prop="address"><el-input v-model.trim="channel.address" autocomplete="off" placeholder="请输入合法测试号码" /></el-form-item></el-form><template #footer><el-button @click="channelDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
</el-card></template></BasicLayout>
</template>
<script>
import { addContactChannel, createContact, listContacts, setContactEnabled, updateContact, validateContactChannel } from '@/api/bell/contact'
export default { name: 'BellContacts', data() { return { loading: false, saving: false, error: '', items: [], total: 0, contactDialog: false, channelDialog: false, editing: false, editingId: '', channelContactId: '', query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }, form: { name: '', role: '', expectedVersion: 0 }, channel: { kind: 'sms', address: '' }, rules: { name: [{ required: true, message: '请输入称呼', trigger: 'blur' }], role: [{ required: true, message: '请输入岗位', trigger: 'blur' }] }, channelRules: { kind: [{ required: true, message: '请选择通道', trigger: 'change' }], address: [{ required: true, pattern: /^\+?[0-9 -]{6,24}$/, message: '请输入有效号码', trigger: 'blur' }] }} }, computed: { canWrite() { const p = this.$store.getters.permisaction || []; return p.includes('*:*:*') || p.includes('bell:contact:write') } }, created() { this.load() }, methods: { async load() { this.loading = true; this.error = ''; try { const r = await listContacts(this.query); this.items = r.data.list || []; this.total = r.data.count || 0 } catch (e) { this.error = e.message || '联系人加载失败' } finally { this.loading = false } }, search() { this.query.pageIndex = 1; this.load() }, reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() }, openCreate() { this.editing = false; this.editingId = ''; this.form = { name: '', role: '', expectedVersion: 0 }; this.contactDialog = true }, openEdit(row) { this.editing = true; this.editingId = row.id; this.form = { name: row.name, role: row.role, expectedVersion: row.version }; this.contactDialog = true }, openChannel(row) { this.channelContactId = row.id; this.channel = { kind: 'sms', address: '' }; this.channelDialog = true }, async saveContact() { try { await this.$refs.contactForm.validate(); this.saving = true; if (this.editing) await updateContact(this.editingId, this.form); else await createContact(this.form); this.msgSuccess('联系人已保存'); this.contactDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async saveChannel() { try { await this.$refs.channelForm.validate(); this.saving = true; await addContactChannel(this.channelContactId, this.channel); this.msgSuccess('通道已加密保存,等待验证'); this.channelDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async toggle(row) { try { await setContactEnabled(row.id, row.enabled, row.version); this.msgSuccess(row.enabled ? '联系人已启用' : '联系人已停用'); await this.load() } catch (e) { row.enabled = !row.enabled; this.error = e.message || '状态更新失败' } }, async validate(row, ch) { try { await this.$confirm(`确认合成验证 ${ch.addressMasked} 成功?本操作不会发送外部消息。`, '记录验证结果', { type: 'warning' }); await validateContactChannel(ch.id, { status: 'verified', detail: '人工合成验证' }); this.msgSuccess('验证事实已记录'); await this.load() } catch (e) { if (e !== 'cancel' && e !== 'close' && e && e.message) this.error = e.message } }, kindName(v) { return { sms: '短信', voice: '语音' }[v] || v }, statusName(v) { return { pending: '待验证', verified: '已验证', failed: '验证失败' }[v] || v }, statusType(v) { return { pending: 'warning', verified: 'success', failed: 'danger' }[v] || 'info' } }}
</script>
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h2{margin:0}.heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state{margin-bottom:16px}.channel{margin:2px 6px 2px 0}.muted{color:var(--el-text-color-secondary)}</style>
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -382,3 +382,20 @@ coordination-common.ps1
协调层不拥有业务数据或契约,不共享用户表、JWT、Cookie、数据库内部模型、摄像头凭据或产品实现。它不替代 `contracts/**`,也不让任一产品成为另一产品的启动前置;connector 可关闭,三端核心能力继续独立运行。根级运行状态位于清单指定的 `runtime_root\state`,产品日志仍归各自日志目录和产品入口管理。
<!-- coordination-deployment-v1:end -->
<!-- bell-contact-schedule:start -->
## 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` 之前注册联系人通道请求体脱敏中间件,使操作日志只能接触固定脱敏内容。
<!-- bell-contact-schedule:end -->
+21 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -287,3 +287,22 @@ synchronized_at: 2026-08-31T07:23:13Z
- **停用规则**:关闭 connector 只停止新接入或投递;不得清空未投递 Outbox、持久 replay、Receipt、Event、证据元数据或审计。
- **独立运行**:Sense、Brain、Bell 不因对端未安装、离线或 connector 关闭而停止各自核心能力;不得以共享数据库/JWT/Cookie 规避故障隔离。
<!-- integration-connectors-v1:end -->
<!-- bell-contact-schedule:start -->
## Bell 联系人与排班术语
- **联系人(Contact)**:可参与 Bell 告警接收和值班安排的人员记录。
- **通知通道(Channel)**:联系人使用的消息到达方式;通道地址密文保存,对外只提供脱敏摘要。
- **通道验证事实(Channel Validation)**:一次独立、只追加的验证结果;“已验证”不等于“已启用”。
- **值班组(Duty Group)**:参与同一排班规则的一组联系人。
- **排班版本(Schedule Version)**:值班组在指定 IANA 时区下的一套完整周排班;发布后冻结,不原位修改。
- **轮值时段(Rotation Slot)**:排班版本内的连续时间段,包含不同的主值班与备值班联系人。
- **临时替班(Override)**:在已发布排班上的只追加例外事实,不改写原排班历史。
规则:
1. 发布前必须验证整周无空档、无重叠,并验证主备不同、成员有效及通知通道已启用且已验证。
2. 已发布排班、通道验证事实、审计事实和临时替班记录保持不可变;更正通过新版本或新事实完成。
3. 同一排班版本中的临时替班不能重叠。
4. 普通操作员只读;创建、修改、启停、验证、发布和替班由管理员执行。
<!-- bell-contact-schedule:end -->
+73 -2
View File
@@ -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: 7e5ec44eb0afec0b84010ce6cf82177491272c70
synchronized_at: 2026-08-31T08:25:49Z
wiki_revision: e90d408200d7189771eeec35ae76a00837e2eedf
synchronized_at: 2026-09-01T04:08:26Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -775,3 +775,74 @@ git diff --check
这些测试证明编排和隔离控制,不证明真实产品包、生产 PostgreSQL、客户 PKI、真实摄像头/GPU、供应商服务、容量或长稳表现。真实契约闭环与 Brain/Bell 离线、重启、重复/冲突及证据降级由后续 E2E 工单验证。
<!-- coordination-deployment-v1:end -->
<!-- coordination-e2e-v1:start -->
## 三项目协调 E2E 验证
从仓库根目录执行唯一默认验收入口:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1
```
可复制的工具参数:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1 -PostgresBin D:\pgsql17\bin -Python Brain\.venv\Scripts\python.exe
```
默认完整运行按以下阶段串行收敛:
1. 创建动态 loopback 端口的临时 PostgreSQL,为 Sense/Bell 建立不同的随机 owner/database,并执行 Bell 正式迁移。
2. 在一次性 Python venv 中验证 source-config、runtime-status、machine-identity、events、evidence 五类冻结 v1 契约。
3. 验证 Sense 配置/状态、Brain 事件接入/证据/Outbox 与 PostgreSQL 并发恢复。
4. 验证 Brain 配置/状态 connector、匿名事件导出和持久重放。
5. 串行验证 Bell ingress/evidence、Rule/Alert 投影和 ack/close 生命周期。共享有状态 Bell 数据库的包不得并行运行。
6. 默认重新运行 Sense isolated E2E、Brain 全套测试和 Bell isolated E2E。
7. 核对 Sense/Bell database owner/database 不同、随机秘密未进入临时日志、所属端口/进程已停止,并安全清理专属临时目录。
#155 的完整通过记录为:source-config 11 项、runtime-status 14 项、machine-identity 10 项、events 7 项、evidence 4 项;Brain connector/event export 25 项、Brain 全套 74 项;Sense/Bell connector 与独立 isolated E2E 均通过。不同提交必须以当次真实输出为准,不复用这些数量冒充新结果。
只有默认完整入口退出码为 0、末行出现 `COORDINATION_E2E passed`、三端独立回归均成功并且工作区无计划外改动,才满足协调 E2E 技术验收。`-SkipIndependentProductE2E` 仅供 harness 调试,不能作为 #155/MVP 验收。`-KeepTemporary` 仅用于失败诊断,保留内容不得提交或共享。
仓库级闭环继续执行:
```powershell
python dev_scripts/harness.py check --strict
python -m unittest discover -s tests -v
python dev_scripts/harness.py sync --check
git diff --check
```
已验证范围不包含真实 GPU/生产模型、真实摄像头、通知供应商、生产迁移、16 路长稳和客户现场效果;这些项目必须在对应环境和独立工单中验收。
<!-- coordination-e2e-v1:end -->
<!-- bell-contact-schedule:start -->
## 验证 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 脚本会验证密文保存、写接口不回显地址、验证与启用状态分离、过期版本冲突、完整周覆盖、已发布历史不可修改、临时替班不重叠、不可变事实约束、操作员只读权限,以及操作日志不含通道明文。当前范围不连接真实外部消息提供商。
<!-- bell-contact-schedule:end -->
+29 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: 0d4a70626b4ab8cc65df3d8a56975376684dd284
synchronized_at: 2026-08-31T08:26:11Z
wiki_revision: eec4beb8e5b9b8ff18401b272fd249aff7711509
synchronized_at: 2026-08-31T13:00:41Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -221,3 +221,30 @@ pwsh -NoProfile -File scripts/runtime/coordination/status-yovision.ps1 -Manifest
日志和状态不得包含环境变量值、密码、JWT、token、私钥、完整 Authorization、摄像头凭据或客户数据。协调层故障时可停止使用根级入口并恢复三个产品各自的已验收启动脚本,不删除数据或共享事实。
<!-- coordination-deployment-v1:end -->
<!-- coordination-e2e-v1:start -->
## 三项目协调 E2E 排错
先从完整输出定位第一个失败阶段,不同时修改多个猜测原因。默认入口是:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1
```
| 现象 | 检查 | 安全处理 |
|---|---|---|
| 缺少 `initdb.exe`、`pg_ctl.exe`、`createdb.exe` 或 `psql.exe` | 检查 `-PostgresBin` 是否指向同一 PostgreSQL 安装的 `bin` | 修正工具路径;不要改用生产数据库或默认 5432 |
| Python 缺少契约依赖 | 确认所选 Python 可创建 venv 且能安装仓库冻结依赖 | 修复 Python/依赖源后重试;不要把依赖临时装进产品环境充当固定基线 |
| Bell 正式迁移失败 | 查看当轮临时目录的 `bell-migrate.log`,核对首个 SQLSTATE | 修复迁移或工具链;不得跳过迁移、手工补表或连接业务库 |
| Sense/Bell 数据库隔离断言失败 | 检查生成的 owner/database 是否不同 | 停止测试;不得共享数据库、角色、JWT、Cookie 或账户空间 |
| Bell Rule/Alert 评估数偶发增加 | 检查是否把 ingress、rule、lifecycle 多个有状态 Go 包放在同一数据库并行运行 | 恢复 root harness 的串行阶段;不要降低断言或清除不可变事实 |
| `machine_token_*`、duplicate 或 conflict 与预期不符 | 核对 audience/scope/kid、请求绑定、jti 和 producer/source 业务键 | 保持稳定错误和原业务键;不得记录 Authorization、复用网页登录态或为重试改 source ID |
| Outbox 在 Bell 离线后未补投 | 核对 retry/lease/available_at、Bell 恢复和新 relay 进程 | 保留消息和历史,恢复 Bell 后重试;不得清空 Outbox/Receipt/Event |
| evidence timeout/unavailable 导致整条事件失败 | 检查 evidence resolver 与降级状态 | Event/Receipt/Alert 应继续保留;不得把失败伪装为 success |
| 端口仍占用或测试结束后有进程 | 确认进程是否由本轮测试启动,查看所属临时目录和状态 | 只停止能证明归属的进程;不得无条件 taskkill 或终止其他实例 |
| 秘密扫描失败 | 在本轮临时日志中搜索该随机测试值,禁止把值粘贴到工单 | 修复日志输出后重新运行;不得仅关闭扫描 |
| 需要保留失败现场 | 使用 `-KeepTemporary` 并记录明确绝对临时路径 | 目录只用于本机受控诊断,完成后按已核对路径清理;不得提交或共享 |
| 使用 `-SkipIndependentProductE2E` 后通过 | 只证明协调 harness 主体,不证明三端独立回归 | 修复依赖后重新运行默认完整入口,不能据此通过 MVP 验收 |
若失败发生在 Sense、Brain 或 Bell 的独立 E2E,转到对应产品章节按该端首个错误排查;不得在协调文档工单中顺手修改产品代码。真实 GPU、真机、通知供应商、生产迁移、16 路长稳或客户现场问题不属于该隔离 E2E 的结论。
<!-- coordination-e2e-v1:end -->
+16 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -294,3 +294,17 @@ Brain 将内部匿名候选映射为标准事件;默认拓扑由 Sense 以机
Sense 的 `local_event.CreateWithOutbox` 是本地候选与标准事件 Outbox 的正式原子写入口;当前仓库尚无生产本地候选创建调用链,不把不存在的上游路径声明为已接通。真实客户 PKI、生产 PostgreSQL、现场网络与最终故障隔离由 #154/#155 验证。
<!-- standard-event-evidence-v1:end -->
<!-- bell-contact-schedule:start -->
## Bell 联系人和值班排班
Bell 的告警接收对象和值班安排由工单 #183 建立,目标是让管理员维护可审计、可验证且不泄露通道地址的通知基础数据。
- 联系人与通知通道分离管理;通道地址只以密文保存,API、操作日志和页面均不得返回或记录明文,只展示脱敏摘要。
- 通道“已启用”和“已验证”是两个独立状态;验证结果以只追加事实记录保存,不能用启用状态替代验证。
- 值班组成员必须是已启用联系人,发布前主值班与备值班必须是不同联系人,且均具有已启用、已验证的通知通道。
- 周排班使用 IANA 时区,必须覆盖完整一周且不允许时间空档或重叠;排班以版本发布,已发布版本不可修改。
- 临时替班作为只追加事实记录保存,不能覆盖或改写已发布历史,且同一排班版本内不得时间重叠。
- 普通操作员只读,管理员可维护与发布。
- 本范围只提供联系人、通道验证事实和值班排班基础能力,不执行真实外部通知;实际发送属于后续工单 #185。
<!-- bell-contact-schedule:end -->
+22 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: f71d21296bc4d402046a52903e6e9ac0583dbd66
synchronized_at: 2026-08-31T08:27:50Z
wiki_revision: c376d69227aa159a7839a829f5bfb5a7beaf89e6
synchronized_at: 2026-08-31T13:12:00Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
@@ -214,3 +214,23 @@ pwsh -NoProfile -File scripts/runtime/coordination/stop-yovision.ps1 -Manifest C
升级时每次只替换一个独立包并更新精确版本,先备份 Sense/Bell,再按 Bell → Sense → Brain 验证,最后启用 connector。回退时先停用 Brain event export、Sense ingress/relay 与 Bell ingress/evidence connector,再使用各产品独立入口回退包或恢复数据库;不得删除 Outbox、Receipt、Event、运行投影、replay 或审计事实。16 路只是当前交付配额,不是编排器硬上限;真实生产包、PostgreSQL、客户 PKI、真机容量与长稳仍需部署环境验收。
<!-- coordination-deployment-v1:end -->
<!-- coordination-e2e-v1:start -->
## 三项目协调 E2E 验收入口
工单 #155 已于 2026-08-31 验收,并通过 PR #170 合入 `dev@0276bce`。该入口只用于隔离开发/验收,不是生产部署、生产迁移或现场容量测试:
```powershell
pwsh scripts/e2e/coordination/run-coordination-e2e.ps1
```
入口要求 PowerShell 7、冻结的 Go 1.26.5 工具链、Brain 可用 Python 环境、PostgreSQL 17 命令行工具,以及 Sense/Bell 各自独立 E2E 已记录的本机依赖。PostgreSQL 工具默认从 `D:\pgsql17\bin` 读取,可通过 `-PostgresBin` 指定其他安装目录;Python 默认优先使用 `Brain\.venv\Scripts\python.exe`,也可通过 `-Python` 指定。不得为通过测试而连接生产数据库、客户设备或生产服务。
每次运行会在系统临时目录创建专属 PostgreSQL cluster,使用非 5432 动态 loopback 端口,并为 Sense、Bell 创建随机且不同的 database owner 和 database。测试凭据只存在于当前进程和临时测试范围,不进入仓库。入口按顺序执行冻结契约、三端 connector/持久化故障验证,并默认继续执行 Sense、Brain、Bell 各自已有的独立 E2E。
`-SkipIndependentProductE2E` 只用于定位协调 harness 自身故障;使用该参数的结果不能作为 #155 或 MVP #156 验收证据。`-KeepTemporary` 只用于受控保留失败诊断,目录可能包含一次性测试数据,排查完成后按明确绝对路径清理,不得提交或共享。
入口在 `finally` 中只停止自己启动的临时 PostgreSQL 和下游测试拥有的进程,核对端口关闭,并扫描临时日志是否出现本轮随机秘密。成功的最终判据是全部阶段退出码为 0,输出末行包含 `COORDINATION_E2E passed`,且 `git status --short` 没有产品源码或配置改动。
该验收证明版本化契约、配置/状态、匿名事件、Sense Outbox、Bell Receipt/Event/Alert/ack/close、离线恢复、身份/重放/冲突/证据降级及三端独立运行;不证明真实 GPU/生产模型、真实摄像头、通知供应商、生产迁移、16 路长稳或客户现场效果。
<!-- coordination-e2e-v1:end -->