feat: 实现 Bell 联系人与值班排班 (#183)

This commit is contained in:
QiuSW
2026-09-01 12:16:52 +08:00
parent 5adee5c3b4
commit e81f00e9aa
26 changed files with 1960 additions and 9 deletions
@@ -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)
}
}
}