Compare commits

..
94 changed files with 11385 additions and 304 deletions
-9
View File
@@ -116,11 +116,6 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
## 6. Git 与验证
- `explore` 是旧实现的只读聚合快照,不接受新功能、修复或文档演进;需要恢复旧行为时从其来源提交读取证据,不在该分支续写产品。
- `main` 是用户审核基线,禁止直接开发、直接提交或未经用户明确审核的合并。
- 新任务分支必须从当前 `dev` 创建,完成后通过 PR 合回 `dev`;不得把功能分支直接合入 `main`。
- `dev` 完成集成测试后仍不能自动进入 `main`;只有用户明确表示审核/验收通过,Agent 才能执行 `dev → main`。
- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。
- 提交只包含当前工单相关文件。
- 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。
- 不为流程制造空提交。
@@ -199,10 +194,6 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- `goadmin-baseline.json` 是 Sense、Bell 共用的 GoAdmin 上游与工具链版本事实来源;上游 URL 加完整 commit 是可复现标识,tag、`package.json` version、本机目录内容或本机已安装版本都不能替代 commit。
- `D:\github\goadmin\go-admin`、`go-admin-ui`、`go-admin-doc` 是当前机器的只读参考副本。Agent 可以读取和验证,不得在这些目录开发、修改或提交 yovision 产品代码。
- “基于 go-admin / go-admin-ui 二次开发”必须表现为从 `goadmin-baseline.json` 指定的两个完整上游 commit 导入或派生产品源码,并保留可核对的目录结构、框架启动链、通用认证/RBAC、菜单路由、请求封装及许可证来源;仅采用 Go、Vue、Element Plus、相似目录、页面外观或交互不算满足基线。
- 禁止用自建 `net/http`、自建认证/RBAC/会话框架或自制管理端外壳替代 go-admin/go-admin-ui 后仍声称是 GoAdmin 二次开发。确有上游能力无法满足时,必须在独立工单中说明缺口、保留上游扩展边界并等待用户确认。
- 每个 Sense/Bell 骨架、基础能力或框架相关工单开始实施前,必须读取任务相关的冻结 `go-admin-doc` 内容,并在工单中记录参考的文档主题或文件、继承的上游路径以及计划隐藏/禁用的默认模块。
- 初始化验收必须包含来源证据:上游 URL/commit、实际导入路径、保留的 MIT 文件、相对上游的裁剪/修改清单和构建测试;缺少任一项不得标记为 GoAdmin 骨架完成。
- Sense、Bell 必须从同一冻结基线分别初始化,保留独立代码、配置、构建、测试、版本和发布;不得通过共同修改参考仓库形成隐藏的共享实现。
- 复制或派生上游代码时必须保留对应 MIT 许可证和版权声明,并记录实际复制来源的 commit。
- `go-admin-doc` 只用于理解上游框架用法;yovision 的 Gitea Wiki、`AGENTS.md` 和 `goadmin-baseline.json` 仍是本项目事实来源。
+6
View File
@@ -0,0 +1,6 @@
# Copy values into the process environment. Do not commit real credentials.
BELL_ENV=development
BELL_HTTP_ADDRESS=127.0.0.1:8082
BELL_DATABASE_URL=postgres://bell_app:replace-at-deploy@127.0.0.1:5432/bell?sslmode=disable
BELL_SESSION_SECRET=generate-an-independent-random-value-at-deploy
BELL_SHUTDOWN_SECONDS=10
-3
View File
@@ -26,9 +26,6 @@
## GoAdmin 技术基线
- 初始化或升级 Bell 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
- Bell 必须由冻结 go-admin 后端与 go-admin-ui 前端源码直接派生;旧 `explore` 实现只能用于业务语义对照,禁止把其中自研认证、RBAC、会话、HTTP 平台或前端外壳复制为新基础。
- 开始每个骨架、认证权限、菜单路由、代码生成、ORM 或通用 UI 工单前,必须读取任务相关的冻结 go-admin-doc,并把参考主题/文件及采用方式写回工单。
- 骨架验收必须证明上游来源、继承路径、许可证、裁剪清单和相对上游差异;只有相似技术栈或视觉不能通过验收。
- `D:\github\goadmin` 下的仓库仅可只读参考;Bell 产品代码只能写入 `Bell/` 或工单授权路径,不得在参考仓库原地开发。
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Bell 可以独立发布,但不得单独漂移上游或工具链版本。
+11
View File
@@ -0,0 +1,11 @@
# Upstream sources
Bell is independently derived from the frozen YoVision GoAdmin baseline.
| Source | Commit | License | Use |
|---|---|---|---|
| https://github.com/go-admin-team/go-admin.git | `f06540883b41d03782bb6b2c4150f298f328c6b6` | MIT | Backend architecture reference |
| https://github.com/go-admin-team/go-admin-ui.git | `67d393d713877572fab0b897296a4c1d525fc81d` | MIT | Vue 3 application shell and component patterns |
The local copies under `D:\github\goadmin` are read-only references and are not Bell source directories.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 go-admin-team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 PanJiaChen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# Bell
Bell is YoVision's independently deployable alert intake and response product. It owns its users, roles, sessions, database, audit facts and release artifacts. Sense and Brain are not required for Bell to build or run.
## Frozen baseline
- Go `1.26.5`
- Node.js `22.22.1`
- pnpm `9.15.1`
- go-admin commit `f06540883b41d03782bb6b2c4150f298f328c6b6`
- go-admin-ui commit `67d393d713877572fab0b897296a4c1d525fc81d`
Exact upstream sources and retained MIT notices are in `LICENSES/`.
## Configuration
Copy variable names from `.env.example` into the process environment. Replace every placeholder at deployment time. Bell intentionally has no built-in database password, session secret or administrator password.
`BELL_SESSION_SECRET` is mandatory, must be at least 32 characters, and must be generated independently from Sense. In production Bell marks its `bell_session` cookie as Secure, HttpOnly and SameSite=Strict.
The PostgreSQL role used by `BELL_DATABASE_URL` must be dedicated to Bell. Do not reuse a Sense role or database.
## Backend
```powershell
Set-Location Bell/server
go test ./...
go run . migrate
go run . serve
```
Create the first administrator only through a one-time process environment value; do not place the password in shell history, source files or command arguments:
```powershell
$env:BELL_BOOTSTRAP_PASSWORD = Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText
go run . create-admin --username bell-admin --display-name "Bell 管理员"
Remove-Item Env:BELL_BOOTSTRAP_PASSWORD
```
Passwords require at least 12 characters with upper-case, lower-case and numeric characters. Bell ships no user or default password. Administrator, operator and viewer roles are seeded with least-privilege permissions; only an administrator can read user and authentication-audit pages.
Health endpoints:
```powershell
Invoke-RestMethod http://127.0.0.1:8082/healthz
Invoke-RestMethod http://127.0.0.1:8082/readyz
```
Migrations are embedded and applied transactionally. The `migrate` command is idempotent. To roll back this initial skeleton, stop Bell and drop only the dedicated Bell test database; migrations are forward-only and never touch another product database.
## Web
```powershell
Set-Location Bell/web
pnpm install --frozen-lockfile
pnpm lint
pnpm build:prod
pnpm dev
```
The web client listens on port 8083 in development and proxies `/api` to Bell only. The visible shell contains only Bell's product navigation; go-admin demonstration routes are neither registered nor served by the backend.
### Development-only synthetic events
When `BELL_ENV=development` or `test`, administrators can open “合成事件测试” or call the `/api/v1/synthetic/*` endpoints. Fixtures live under `server/testdata/events/`, contain only invented anonymous data, and call the same Event/Receipt service as every future producer adapter. Reusing a source event ID verifies idempotency; using the same ID with a different fixture verifies conflict handling.
In `BELL_ENV=production` those routes are not registered and return `404`; hiding the menu is not the security boundary.
### Rules and alerts
Administrators configure and enable rules from “规则配置”. Each accepted Event is evaluated in the same database transaction that creates its Event/Receipt. A matching Event creates or joins an open Alert correlated by rule and location; one Event may match several rules and one Alert may collect several related Events. Every evaluation records its rule version and explanation, while every match also stores the actual rule snapshot. Unmatched Events remain visible as Events and are never labelled as notified or handled.
Alert handling is two-step: the first authorized operator to acknowledge becomes the handler, then that handler (or an administrator) records a required site outcome before closing. PostgreSQL conditional updates select one concurrent acknowledgement winner. Later operators receive the actual handler instead of overwriting it. Successful lifecycle facts are append-only; failed, duplicate and denied attempts are also written to the security audit without tokens or passwords.
## Independent smoke check
Run PostgreSQL with an empty Bell-only database, set `BELL_DATABASE_URL`, start the backend and then the web client. Keep Sense and Brain stopped. Verify `/healthz`, `/readyz`, the workbench shell, and a direct request to an unregistered framework demo route returns `404`.
## Logs and secrets
Application logs go to standard output. Never log database URLs, passwords, session tokens, event evidence credentials, customer data or production payloads.
@@ -0,0 +1,11 @@
$ErrorActionPreference = 'Stop'
$expectedGo = 'go version go1.26.5 windows/amd64'
$expectedNode = 'v22.22.1'
$expectedPnpm = '9.15.1'
if ((go version) -ne $expectedGo) { throw "Expected $expectedGo" }
if ((node --version) -ne $expectedNode) { throw "Expected Node $expectedNode" }
if ((pnpm --version) -ne $expectedPnpm) { throw "Expected pnpm $expectedPnpm" }
Write-Output 'Bell toolchain matches goadmin-baseline.json.'
+58
View File
@@ -0,0 +1,58 @@
package lifecycle
import (
"encoding/json"
"errors"
"net/http"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
)
type HTTP struct {
Service Service
Auth auth.HTTP
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("POST /api/v1/alerts/{id}/ack", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.ack)))
mux.Handle("POST /api/v1/alerts/{id}/close", h.Auth.Require(rbac.AlertsHandle, http.HandlerFunc(h.close)))
}
func (h HTTP) ack(w http.ResponseWriter, r *http.Request) {
result, err := h.Service.Ack(r.Context(), r.PathValue("id"), auth.Principal(r.Context()))
if err != nil {
status := 500
if errors.Is(err, ErrAlreadyHandled) {
status = 409
}
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
return
}
writeJSON(w, 200, result)
}
func (h HTTP) close(w http.ResponseWriter, r *http.Request) {
var input CloseInput
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
return
}
result, err := h.Service.Close(r.Context(), r.PathValue("id"), input, auth.Principal(r.Context()))
if err != nil {
status := 500
if errors.Is(err, ErrOutcomeRequired) {
status = 400
} else if errors.Is(err, ErrInvalidTransition) {
status = 409
}
writeJSON(w, status, map[string]any{"error": err.Error(), "current": result.Detail})
return
}
writeJSON(w, 200, result)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+128
View File
@@ -0,0 +1,128 @@
package lifecycle
import (
"context"
"encoding/json"
"errors"
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
var ErrInvalidTransition = errors.New("当前状态不能执行此操作")
var ErrOutcomeRequired = errors.New("请选择现场处理结果")
type CloseInput struct {
Outcome string `json:"outcome"`
Note string `json:"note"`
}
type Result struct {
Detail model.Detail `json:"detail"`
Idempotent bool `json:"idempotent"`
}
type Service struct {
DB *pgxpool.Pool
Audit audit.Store
Query alertQuery.Service
}
func hasRole(user auth.User, role string) bool {
for _, value := range user.Roles {
if value == role {
return true
}
}
return false
}
func validOutcome(value string) bool {
return value == "danger_confirmed" || value == "false_positive" || value == "site_normal" || value == "unable_to_confirm"
}
func (s Service) Ack(ctx context.Context, alertID string, actor auth.User) (Result, error) {
tx, err := s.DB.Begin(ctx)
if err != nil {
return Result{}, err
}
var updated string
err = tx.QueryRow(ctx, `UPDATE bell_alerts SET status='acknowledged',acknowledged_by=$2,acknowledged_at=now(),updated_at=now() WHERE id=$1 AND status='open' RETURNING id::text`, alertID, actor.ID).Scan(&updated)
if err == nil {
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id) VALUES($1,'acknowledged',$2)`, alertID, actor.ID); err == nil {
_, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.ack','alert',$2,'success','{}')`, actor.ID, alertID)
}
if err == nil {
err = tx.Commit(ctx)
}
if err != nil {
_ = tx.Rollback(ctx)
return Result{}, err
}
detail, err := s.Query.Get(ctx, alertID)
return Result{Detail: detail}, err
}
_ = tx.Rollback(ctx)
if !errors.Is(err, pgx.ErrNoRows) {
return Result{}, err
}
detail, getErr := s.Query.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Alert.AcknowledgedBy != nil && *detail.Alert.AcknowledgedBy == actor.ID {
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "duplicate"})
return Result{Detail: detail, Idempotent: true}, nil
}
_ = s.Audit.Record(ctx, &actor.ID, "alert.ack", "alert", &alertID, "failure", map[string]any{"reason": "already_handled"})
return Result{Detail: detail}, ErrAlreadyHandled
}
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor auth.User) (Result, error) {
if !validOutcome(input.Outcome) {
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "outcome_required"})
return Result{}, ErrOutcomeRequired
}
current, err := s.Query.Get(ctx, alertID)
if err != nil {
return Result{}, err
}
if current.Alert.Status == "closed" {
if current.Alert.ClosedBy != nil && *current.Alert.ClosedBy == actor.ID && current.Alert.CloseOutcome != nil && *current.Alert.CloseOutcome == input.Outcome {
return Result{Detail: current, Idempotent: true}, nil
}
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "already_closed"})
return Result{Detail: current}, ErrInvalidTransition
}
admin := hasRole(actor, "administrator")
if current.Alert.Status != "acknowledged" || (!admin && (current.Alert.AcknowledgedBy == nil || *current.Alert.AcknowledgedBy != actor.ID)) {
_ = s.Audit.Record(ctx, &actor.ID, "alert.close", "alert", &alertID, "failure", map[string]any{"reason": "invalid_owner_or_state"})
return Result{Detail: current}, ErrInvalidTransition
}
details, _ := json.Marshal(map[string]any{"outcome": input.Outcome, "note": input.Note})
tx, err := s.DB.Begin(ctx)
if err != nil {
return Result{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
result, err := tx.Exec(ctx, `UPDATE bell_alerts SET status='closed',closed_by=$2,closed_at=now(),close_outcome=$3,close_note=nullif($4,''),updated_at=now() WHERE id=$1 AND status='acknowledged'`, alertID, actor.ID, input.Outcome, input.Note)
if err != nil {
return Result{}, err
}
if result.RowsAffected() != 1 {
return Result{}, ErrInvalidTransition
}
if _, err = tx.Exec(ctx, `INSERT INTO bell_alert_lifecycle_facts(alert_id,transition,actor_user_id,details) VALUES($1,'closed',$2,$3::jsonb)`, alertID, actor.ID, string(details)); err != nil {
return Result{}, err
}
if _, err = tx.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,'alert.close','alert',$2,'success',$3::jsonb)`, actor.ID, alertID, string(details)); err != nil {
return Result{}, err
}
if err = tx.Commit(ctx); err != nil {
return Result{}, err
}
detail, err := s.Query.Get(ctx, alertID)
return Result{Detail: detail}, err
}
@@ -0,0 +1,135 @@
package lifecycle
import (
"context"
"fmt"
"os"
"sync"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
func TestConcurrentAckCloseAndRestart(t *testing.T) {
url := os.Getenv("BELL_TEST_DATABASE_URL")
if url == "" {
t.Skip("BELL_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
db, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
if err := migrations.Apply(ctx, db); err != nil {
t.Fatal(err)
}
prefix := fmt.Sprintf("lifecycle-%d", time.Now().UnixNano())
actors := make([]auth.User, 2)
for i := range actors {
var id string
username := fmt.Sprintf("%s-%d", prefix, i)
if err := db.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,'test-only-not-a-login-hash') RETURNING id::text`, username, username).Scan(&id); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='operator'`, id); err != nil {
t.Fatal(err)
}
actors[i] = auth.User{ID: id, Username: username, DisplayName: username, Roles: []string{"operator"}}
}
eventType := prefix + "-event"
ruleService := rule.Service{DB: db}
createdRule, err := ruleService.Create(ctx, rule.CreateInput{Code: prefix, Name: "生命周期测试规则", EventType: &eventType, MinimumSeverity: "low"})
if err != nil {
t.Fatal(err)
}
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
accepted, err := eventService.Ingest(ctx, event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "test place", Severity: "high", Attributes: map[string]any{}})
if err != nil {
t.Fatal(err)
}
queryService := query.Service{DB: db}
alerts, err := queryService.ForEvent(ctx, accepted.Event.ID)
if err != nil {
t.Fatal(err)
}
alertID := ""
for _, item := range alerts {
if item.PrimaryRuleID == createdRule.ID {
alertID = item.ID
}
}
if alertID == "" {
t.Fatal("alert not created")
}
service := Service{DB: db, Audit: audit.Store{DB: db}, Query: queryService}
const attempts = 20
var wg sync.WaitGroup
for i := 0; i < attempts; i++ {
wg.Add(1)
actor := actors[i%2]
go func() { defer wg.Done(); _, _ = service.Ack(ctx, alertID, actor) }()
}
wg.Wait()
detail, err := queryService.Get(ctx, alertID)
if err != nil {
t.Fatal(err)
}
if detail.Alert.Status != "acknowledged" || detail.Alert.AcknowledgedBy == nil {
t.Fatalf("unexpected ack projection: %#v", detail.Alert)
}
var facts int
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1 AND transition='acknowledged'`, alertID).Scan(&facts); err != nil {
t.Fatal(err)
}
if facts != 1 {
t.Fatalf("ack facts=%d", facts)
}
winner := actors[0]
loser := actors[1]
if winner.ID != *detail.Alert.AcknowledgedBy {
winner, loser = loser, winner
}
if _, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal"}, loser); err == nil {
t.Fatal("non-owner closed alert")
}
closed, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
if err != nil {
t.Fatal(err)
}
if closed.Detail.Alert.Status != "closed" {
t.Fatal("alert not closed")
}
repeat, err := service.Close(ctx, alertID, CloseInput{Outcome: "site_normal", Note: "现场检查正常"}, winner)
if err != nil || !repeat.Idempotent {
t.Fatalf("repeat close: %#v %v", repeat, err)
}
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID).Scan(&facts); err != nil {
t.Fatal(err)
}
if facts != 2 {
t.Fatalf("lifecycle facts=%d", facts)
}
if _, err := db.Exec(ctx, `DELETE FROM bell_alert_lifecycle_facts WHERE alert_id=$1`, alertID); err == nil {
t.Fatal("lifecycle deletion succeeded")
}
db.Close()
reopened, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
afterRestart, err := (query.Service{DB: reopened}).Get(ctx, alertID)
if err != nil {
t.Fatal(err)
}
if afterRestart.Alert.Status != "closed" || len(afterRestart.Timeline) != 2 {
t.Fatalf("restart projection: %#v", afterRestart)
}
}
+53
View File
@@ -0,0 +1,53 @@
package model
import "encoding/json"
type Alert struct {
ID string `json:"id"`
Status string `json:"status"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Location string `json:"location"`
PrimaryRuleID string `json:"primary_rule_id"`
RuleName string `json:"rule_name"`
EventCount int `json:"event_count"`
AcknowledgedBy *string `json:"acknowledged_by,omitempty"`
AcknowledgedByName *string `json:"acknowledged_by_name,omitempty"`
AcknowledgedAt *string `json:"acknowledged_at,omitempty"`
ClosedBy *string `json:"closed_by,omitempty"`
ClosedByName *string `json:"closed_by_name,omitempty"`
ClosedAt *string `json:"closed_at,omitempty"`
CloseOutcome *string `json:"close_outcome,omitempty"`
CloseNote *string `json:"close_note,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type LinkedEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
OccurredAt string `json:"occurred_at"`
Location string `json:"location"`
Severity string `json:"severity"`
}
type Match struct {
EventID string `json:"event_id"`
RuleID string `json:"rule_id"`
RuleVersion int `json:"rule_version"`
RuleSnapshot json.RawMessage `json:"rule_snapshot"`
Explanation string `json:"explanation"`
MatchedAt string `json:"matched_at"`
}
type Detail struct {
Alert Alert `json:"alert"`
Events []LinkedEvent `json:"events"`
Matches []Match `json:"matches"`
Timeline []TimelineEntry `json:"timeline"`
}
type TimelineEntry struct {
ID int64 `json:"id"`
Transition string `json:"transition"`
ActorUserID string `json:"actor_user_id"`
ActorName string `json:"actor_name"`
OccurredAt string `json:"occurred_at"`
Details json.RawMessage `json:"details"`
}
+61
View File
@@ -0,0 +1,61 @@
package query
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
"github.com/jackc/pgx/v5"
)
type HTTP struct {
Service Service
Auth auth.HTTP
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/alerts", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.list)))
mux.Handle("GET /api/v1/alerts/{id}", h.Auth.Require(rbac.AlertsRead, http.HandlerFunc(h.get)))
mux.Handle("GET /api/v1/events/{id}/alerts", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.forEvent)))
}
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
if err != nil {
writeJSON(w, 500, map[string]string{"error": "读取预警失败"})
return
}
next := ""
if len(items) > 0 {
next = items[len(items)-1].ID
}
writeJSON(w, 200, map[string]any{"items": items, "next": next})
}
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
if err != nil {
status := 500
if errors.Is(err, pgx.ErrNoRows) {
status = 404
}
writeJSON(w, status, map[string]string{"error": "预警不存在"})
return
}
writeJSON(w, 200, item)
}
func (h HTTP) forEvent(w http.ResponseWriter, r *http.Request) {
items, err := h.Service.ForEvent(r.Context(), r.PathValue("id"))
if err != nil {
writeJSON(w, 500, map[string]string{"error": "读取关联预警失败"})
return
}
writeJSON(w, 200, map[string]any{"items": items})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+102
View File
@@ -0,0 +1,102 @@
package query
import (
"context"
"git.ilapage.cn/ila/yovision/Bell/server/app/alert/model"
"github.com/jackc/pgx/v5/pgxpool"
)
type Service struct{ DB *pgxpool.Pool }
const base = `SELECT a.id::text,a.status,a.severity,a.summary,a.location,a.primary_rule_id::text,r.name,(SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id=a.id),a.acknowledged_by::text,ack_user.display_name,a.acknowledged_at::text,a.closed_by::text,close_user.display_name,a.closed_at::text,a.close_outcome,a.close_note,a.created_at::text,a.updated_at::text FROM bell_alerts a JOIN bell_rules r ON r.id=a.primary_rule_id LEFT JOIN bell_users ack_user ON ack_user.id=a.acknowledged_by LEFT JOIN bell_users close_user ON close_user.id=a.closed_by`
func scanAlert(row interface{ Scan(...any) error }) (model.Alert, error) {
var item model.Alert
err := row.Scan(&item.ID, &item.Status, &item.Severity, &item.Summary, &item.Location, &item.PrimaryRuleID, &item.RuleName, &item.EventCount, &item.AcknowledgedBy, &item.AcknowledgedByName, &item.AcknowledgedAt, &item.ClosedBy, &item.ClosedByName, &item.ClosedAt, &item.CloseOutcome, &item.CloseNote, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
func (s Service) List(ctx context.Context, limit int, before string) ([]model.Alert, error) {
if limit < 1 || limit > 100 {
limit = 25
}
rows, err := s.DB.Query(ctx, base+` WHERE ($2='' OR (a.created_at,a.id)<((SELECT created_at FROM bell_alerts WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY a.created_at DESC,a.id DESC LIMIT $1`, limit, before)
if err != nil {
return nil, err
}
defer rows.Close()
items := []model.Alert{}
for rows.Next() {
item, err := scanAlert(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Service) Get(ctx context.Context, id string) (model.Detail, error) {
item, err := scanAlert(s.DB.QueryRow(ctx, base+` WHERE a.id=$1`, id))
if err != nil {
return model.Detail{}, err
}
detail := model.Detail{Alert: item, Events: []model.LinkedEvent{}, Matches: []model.Match{}, Timeline: []model.TimelineEntry{}}
rows, err := s.DB.Query(ctx, `SELECT e.id::text,e.event_type,e.occurred_at::text,e.location,e.severity FROM bell_alert_events ae JOIN bell_events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, id)
if err != nil {
return model.Detail{}, err
}
for rows.Next() {
var linked model.LinkedEvent
if err := rows.Scan(&linked.ID, &linked.EventType, &linked.OccurredAt, &linked.Location, &linked.Severity); err != nil {
rows.Close()
return model.Detail{}, err
}
detail.Events = append(detail.Events, linked)
}
rows.Close()
rows, err = s.DB.Query(ctx, `SELECT event_id::text,rule_id::text,rule_version,rule_snapshot,explanation,matched_at::text FROM bell_rule_matches WHERE alert_id=$1 ORDER BY matched_at,event_id`, id)
if err != nil {
return model.Detail{}, err
}
defer rows.Close()
for rows.Next() {
var match model.Match
if err := rows.Scan(&match.EventID, &match.RuleID, &match.RuleVersion, &match.RuleSnapshot, &match.Explanation, &match.MatchedAt); err != nil {
return model.Detail{}, err
}
detail.Matches = append(detail.Matches, match)
}
if err := rows.Err(); err != nil {
return model.Detail{}, err
}
rows.Close()
rows, err = s.DB.Query(ctx, `SELECT f.id,f.transition,f.actor_user_id::text,u.display_name,f.occurred_at::text,f.details FROM bell_alert_lifecycle_facts f JOIN bell_users u ON u.id=f.actor_user_id WHERE f.alert_id=$1 ORDER BY f.id`, id)
if err != nil {
return model.Detail{}, err
}
defer rows.Close()
for rows.Next() {
var entry model.TimelineEntry
if err := rows.Scan(&entry.ID, &entry.Transition, &entry.ActorUserID, &entry.ActorName, &entry.OccurredAt, &entry.Details); err != nil {
return model.Detail{}, err
}
detail.Timeline = append(detail.Timeline, entry)
}
return detail, rows.Err()
}
func (s Service) ForEvent(ctx context.Context, eventID string) ([]model.Alert, error) {
rows, err := s.DB.Query(ctx, base+` JOIN bell_alert_events link ON link.alert_id=a.id WHERE link.event_id=$1 ORDER BY a.created_at,a.id`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []model.Alert{}
for rows.Next() {
item, err := scanAlert(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
+56
View File
@@ -0,0 +1,56 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
type Entry struct {
ID int64 `json:"id"`
OccurredAt string `json:"occurred_at"`
ActorUserID *string `json:"actor_user_id,omitempty"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID *string `json:"target_id,omitempty"`
Outcome string `json:"outcome"`
Details map[string]any `json:"details"`
}
type Store struct{ DB *pgxpool.Pool }
func (s Store) Record(ctx context.Context, actorUserID *string, action, targetType string, targetID *string, outcome string, details map[string]any) error {
data, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("encode audit details: %w", err)
}
_, err = s.DB.Exec(ctx, `INSERT INTO bell_audit_log(actor_user_id,action,target_type,target_id,outcome,details) VALUES($1,$2,$3,$4,$5,$6)`, actorUserID, action, targetType, targetID, outcome, data)
if err != nil {
return fmt.Errorf("append audit entry: %w", err)
}
return nil
}
func (s Store) List(ctx context.Context, limit int) ([]Entry, error) {
if limit < 1 || limit > 200 {
limit = 50
}
rows, err := s.DB.Query(ctx, `SELECT id,occurred_at::text,actor_user_id::text,action,target_type,target_id,outcome,details FROM bell_audit_log ORDER BY id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]Entry, 0, limit)
for rows.Next() {
var e Entry
var data []byte
if err := rows.Scan(&e.ID, &e.OccurredAt, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Outcome, &data); err != nil {
return nil, err
}
_ = json.Unmarshal(data, &e.Details)
entries = append(entries, e)
}
return entries, rows.Err()
}
+179
View File
@@ -0,0 +1,179 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
)
const CookieName = "bell_session"
type HTTP struct {
Service Service
Store Store
Audit audit.Store
SecureCookie bool
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/auth/login", h.login)
mux.Handle("GET /api/v1/auth/me", h.authenticated(http.HandlerFunc(h.me)))
mux.Handle("POST /api/v1/auth/logout", h.authenticated(http.HandlerFunc(h.logout)))
mux.Handle("GET /api/v1/users", h.Require(rbac.UsersRead, http.HandlerFunc(h.users)))
mux.Handle("POST /api/v1/users", h.Require(rbac.UsersWrite, http.HandlerFunc(h.createUser)))
mux.Handle("PUT /api/v1/users/{id}/roles", h.Require(rbac.UsersWrite, http.HandlerFunc(h.replaceRoles)))
mux.Handle("GET /api/v1/audit", h.Require(rbac.AuditRead, http.HandlerFunc(h.auditEntries)))
}
func (h HTTP) login(w http.ResponseWriter, r *http.Request) {
var input LoginInput
if err := decodeJSON(w, r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := h.Service.Login(r.Context(), input)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, ErrInvalidCredentials) {
status = http.StatusUnauthorized
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
h.setCookie(w, result.Token, time.Now().Add(8*time.Hour))
result.User.PasswordHash = ""
writeJSON(w, http.StatusOK, map[string]any{"user": result.User, "expires_at": result.ExpiresAt})
}
func (h HTTP) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, principal(r.Context()))
}
func (h HTTP) logout(w http.ResponseWriter, r *http.Request) {
cookie, _ := r.Cookie(CookieName)
token := ""
if cookie != nil {
token = cookie.Value
}
user := principal(r.Context())
if err := h.Service.Logout(r.Context(), token, user); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "退出失败"})
return
}
h.setCookie(w, "", time.Unix(0, 0))
w.WriteHeader(http.StatusNoContent)
}
func (h HTTP) users(w http.ResponseWriter, r *http.Request) {
users, err := h.Store.ListUsers(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取用户失败"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": users})
}
func (h HTTP) createUser(w http.ResponseWriter, r *http.Request) {
var input struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := decodeJSON(w, r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
if input.Username == "" || input.DisplayName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "用户名和显示名称必填"})
return
}
hash, err := hashPassword(input.Password)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
id, err := h.Store.CreateUser(r.Context(), input.Username, input.DisplayName, hash, input.Role)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "用户或角色无效"})
return
}
actor := principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.user_created", "user", &id, "success", map[string]any{"role": input.Role})
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (h HTTP) replaceRoles(w http.ResponseWriter, r *http.Request) {
var input struct {
Roles []string `json:"roles"`
}
if err := decodeJSON(w, r, &input); err != nil || len(input.Roles) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "至少选择一个角色"})
return
}
id := r.PathValue("id")
if err := h.Store.ReplaceRoles(r.Context(), id, input.Roles); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "角色更新失败"})
return
}
actor := principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rbac.roles_replaced", "user", &id, "success", map[string]any{"roles": input.Roles})
w.WriteHeader(http.StatusNoContent)
}
func (h HTTP) auditEntries(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
entries, err := h.Audit.List(r.Context(), limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取审计失败"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": entries})
}
func (h HTTP) authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
return
}
user, err := h.Service.Authenticate(r.Context(), cookie.Value)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": ErrUnauthorized.Error()})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, user)))
})
}
func (h HTTP) Require(permission string, next http.Handler) http.Handler {
return h.authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := principal(r.Context())
if !user.Permissions.Has(permission) {
_ = h.Audit.Record(r.Context(), &user.ID, "rbac.denied", "permission", &permission, "denied", map[string]any{})
writeJSON(w, http.StatusForbidden, map[string]string{"error": "没有执行此操作的权限"})
return
}
next.ServeHTTP(w, r)
}))
}
func (h HTTP) setCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: value, Path: "/", HttpOnly: true, Secure: h.SecureCookie, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
}
func principal(ctx context.Context) User { user, _ := ctx.Value(principalKey{}).(User); return user }
func Principal(ctx context.Context) User { return principal(ctx) }
func decodeJSON(w http.ResponseWriter, r *http.Request, value any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(value)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"errors"
"fmt"
"unicode"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
func validatePassword(value string) error {
if utf8.RuneCountInString(value) < 12 {
return errors.New("密码至少需要 12 个字符")
}
var lower, upper, digit bool
for _, r := range value {
lower = lower || unicode.IsLower(r)
upper = upper || unicode.IsUpper(r)
digit = digit || unicode.IsDigit(r)
}
if !lower || !upper || !digit {
return errors.New("密码必须同时包含大写字母、小写字母和数字")
}
return nil
}
func hashPassword(value string) (string, error) {
if err := validatePassword(value); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
func passwordMatches(hash, value string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(value)) == nil
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import "testing"
func TestPasswordPolicy(t *testing.T) {
for _, value := range []string{"short", "alllowercase123", "ALLUPPERCASE123"} {
if validatePassword(value) == nil {
t.Fatalf("expected %q to fail policy", value)
}
}
if err := validatePassword("Bell-Safe-2026"); err != nil {
t.Fatalf("valid password rejected: %v", err)
}
}
func TestPasswordHashRoundTrip(t *testing.T) {
hash, err := hashPassword("Bell-Safe-2026")
if err != nil {
t.Fatal(err)
}
if !passwordMatches(hash, "Bell-Safe-2026") {
t.Fatal("password did not match")
}
if passwordMatches(hash, "Wrong-Safe-2026") {
t.Fatal("wrong password matched")
}
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"github.com/jackc/pgx/v5"
)
var ErrInvalidCredentials = errors.New("用户名或密码错误")
var ErrUnauthorized = errors.New("会话无效或已过期")
type Service struct {
Store Store
Audit audit.Store
Secret []byte
SessionTTL time.Duration
}
func (s Service) digest(token string) []byte {
mac := hmac.New(sha256.New, s.Secret)
_, _ = mac.Write([]byte(token))
return mac.Sum(nil)
}
func (s Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
username := strings.ToLower(strings.TrimSpace(input.Username))
user, err := s.Store.UserByUsername(ctx, username)
if err != nil || !user.Enabled || !passwordMatches(user.PasswordHash, input.Password) {
_ = s.Audit.Record(ctx, nil, "auth.login", "user", nil, "failure", map[string]any{"reason": "invalid_credentials"})
return LoginResult{}, ErrInvalidCredentials
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return LoginResult{}, err
}
token := base64.RawURLEncoding.EncodeToString(bytes)
expires := time.Now().UTC().Add(s.SessionTTL)
if s.SessionTTL == 0 {
expires = time.Now().UTC().Add(8 * time.Hour)
}
if err := s.Store.CreateSession(ctx, user.ID, s.digest(token), expires); err != nil {
return LoginResult{}, err
}
_ = s.Audit.Record(ctx, &user.ID, "auth.login", "user", &user.ID, "success", map[string]any{})
return LoginResult{Token: token, User: user, ExpiresAt: expires.Format(time.RFC3339)}, nil
}
func (s Service) Authenticate(ctx context.Context, token string) (User, error) {
if token == "" {
return User{}, ErrUnauthorized
}
user, err := s.Store.UserBySessionDigest(ctx, s.digest(token))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrUnauthorized
}
return User{}, err
}
if !user.Enabled {
return User{}, ErrUnauthorized
}
return user, nil
}
func (s Service) Logout(ctx context.Context, token string, user User) error {
if token != "" {
if err := s.Store.RevokeSession(ctx, s.digest(token)); err != nil {
return err
}
}
return s.Audit.Record(ctx, &user.ID, "auth.logout", "user", &user.ID, "success", map[string]any{})
}
func (s Service) BootstrapAdministrator(ctx context.Context, username, displayName, password string) error {
username = strings.ToLower(strings.TrimSpace(username))
if username == "" {
return errors.New("username is required")
}
hash, err := hashPassword(password)
if err != nil {
return err
}
id, err := s.Store.BootstrapAdministrator(ctx, username, displayName, hash)
if err != nil {
return err
}
return s.Audit.Record(ctx, &id, "rbac.bootstrap_administrator", "user", &id, "success", map[string]any{})
}
+164
View File
@@ -0,0 +1,164 @@
package auth
import (
"context"
"fmt"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct{ DB *pgxpool.Pool }
func (s Store) UserByUsername(ctx context.Context, username string) (User, error) {
var user User
err := s.DB.QueryRow(ctx, `SELECT id::text,username,display_name,password_hash,enabled FROM bell_users WHERE username=$1`, username).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
if err != nil {
return User{}, err
}
if err = s.loadAccess(ctx, &user); err != nil {
return User{}, err
}
return user, nil
}
func (s Store) UserBySessionDigest(ctx context.Context, digest []byte) (User, error) {
var user User
err := s.DB.QueryRow(ctx, `SELECT u.id::text,u.username,u.display_name,u.password_hash,u.enabled FROM bell_sessions s JOIN bell_users u ON u.id=s.user_id WHERE s.token_digest=$1 AND s.revoked_at IS NULL AND s.expires_at>now()`, digest).Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &user.Enabled)
if err != nil {
return User{}, err
}
if err = s.loadAccess(ctx, &user); err != nil {
return User{}, err
}
return user, nil
}
func (s Store) loadAccess(ctx context.Context, user *User) error {
rows, err := s.DB.Query(ctx, `SELECT DISTINCT r.code,p.permission_code FROM bell_user_roles ur JOIN bell_roles r ON r.id=ur.role_id LEFT JOIN bell_role_permissions p ON p.role_id=r.id WHERE ur.user_id=$1 ORDER BY r.code,p.permission_code`, user.ID)
if err != nil {
return err
}
defer rows.Close()
user.Permissions = rbac.Set{}
roleSeen := map[string]bool{}
for rows.Next() {
var role string
var permission *string
if err := rows.Scan(&role, &permission); err != nil {
return err
}
if !roleSeen[role] {
user.Roles = append(user.Roles, role)
roleSeen[role] = true
}
if permission != nil {
user.Permissions[*permission] = struct{}{}
}
}
return rows.Err()
}
func (s Store) CreateSession(ctx context.Context, userID string, digest []byte, expires time.Time) error {
_, err := s.DB.Exec(ctx, `INSERT INTO bell_sessions(user_id,token_digest,expires_at) VALUES($1,$2,$3)`, userID, digest, expires)
return err
}
func (s Store) RevokeSession(ctx context.Context, digest []byte) error {
_, err := s.DB.Exec(ctx, `UPDATE bell_sessions SET revoked_at=COALESCE(revoked_at,now()) WHERE token_digest=$1`, digest)
return err
}
func (s Store) BootstrapAdministrator(ctx context.Context, username, displayName, passwordHash string) (string, error) {
tx, err := s.DB.Begin(ctx)
if err != nil {
return "", err
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) ON CONFLICT(username) DO NOTHING RETURNING id::text`, username, displayName, passwordHash).Scan(&id)
if err != nil {
if err == pgx.ErrNoRows {
return "", fmt.Errorf("administrator %q already exists", username)
}
return "", err
}
if _, err = tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code='administrator'`, id); err != nil {
return "", err
}
if err = tx.Commit(ctx); err != nil {
return "", err
}
return id, nil
}
func (s Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.DB.Query(ctx, `SELECT id::text,username,display_name,enabled FROM bell_users ORDER BY username LIMIT 200`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Enabled); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range users {
if err := s.loadAccess(ctx, &users[i]); err != nil {
return nil, err
}
}
return users, nil
}
func (s Store) CreateUser(ctx context.Context, username, displayName, passwordHash, role string) (string, error) {
tx, err := s.DB.Begin(ctx)
if err != nil {
return "", err
}
defer func() { _ = tx.Rollback(ctx) }()
var id string
if err = tx.QueryRow(ctx, `INSERT INTO bell_users(username,display_name,password_hash) VALUES($1,$2,$3) RETURNING id::text`, username, displayName, passwordHash).Scan(&id); err != nil {
return "", err
}
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, id, role)
if err != nil {
return "", err
}
if result.RowsAffected() != 1 {
return "", fmt.Errorf("unknown role %q", role)
}
if err = tx.Commit(ctx); err != nil {
return "", err
}
return id, nil
}
func (s Store) ReplaceRoles(ctx context.Context, userID string, roles []string) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err = tx.Exec(ctx, `DELETE FROM bell_user_roles WHERE user_id=$1`, userID); err != nil {
return err
}
for _, role := range roles {
result, err := tx.Exec(ctx, `INSERT INTO bell_user_roles(user_id,role_id) SELECT $1,id FROM bell_roles WHERE code=$2`, userID, role)
if err != nil {
return err
}
if result.RowsAffected() != 1 {
return fmt.Errorf("unknown role %q", role)
}
}
return tx.Commit(ctx)
}
+25
View File
@@ -0,0 +1,25 @@
package auth
import "git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Enabled bool `json:"enabled"`
Roles []string `json:"roles"`
Permissions rbac.Set `json:"permissions"`
PasswordHash string `json:"-"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResult struct {
Token string
User User
ExpiresAt string
}
type principalKey struct{}
+52
View File
@@ -0,0 +1,52 @@
package event
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
"github.com/jackc/pgx/v5"
)
type HTTP struct {
Service Service
Auth auth.HTTP
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/events", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.list)))
mux.Handle("GET /api/v1/events/{id}", h.Auth.Require(rbac.EventsRead, http.HandlerFunc(h.get)))
}
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.Service.List(r.Context(), limit, r.URL.Query().Get("before"))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
return
}
next := ""
if len(items) > 0 {
next = items[len(items)-1].ID
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next": next})
}
func (h HTTP) get(w http.ResponseWriter, r *http.Request) {
item, err := h.Service.Get(r.Context(), r.PathValue("id"))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "事件不存在"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取事件失败"})
return
}
writeJSON(w, http.StatusOK, item)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+143
View File
@@ -0,0 +1,143 @@
package event
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrConflict = errors.New("幂等键已用于不同事件载荷")
var ErrInvalid = errors.New("事件字段无效")
type PersistHook func(context.Context, pgx.Tx, Event) error
type Service struct {
DB *pgxpool.Pool
AfterPersist PersistHook
}
func normalize(command Command) ([]byte, [32]byte, error) {
command.ProducerID = strings.TrimSpace(command.ProducerID)
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
command.EventType = strings.TrimSpace(command.EventType)
command.Location = strings.TrimSpace(command.Location)
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
command.OccurredAt = command.OccurredAt.UTC()
if command.ProducerID == "" || command.SourceEventID == "" || command.EventType == "" || command.Location == "" || command.OccurredAt.IsZero() {
return nil, [32]byte{}, ErrInvalid
}
if command.Severity != "low" && command.Severity != "medium" && command.Severity != "high" && command.Severity != "critical" {
return nil, [32]byte{}, ErrInvalid
}
if command.EvidenceRef != nil {
value := strings.TrimSpace(*command.EvidenceRef)
if value == "" || strings.Contains(value, "@") || strings.Contains(value, "\\") || strings.HasPrefix(strings.ToLower(value), "file:") {
return nil, [32]byte{}, fmt.Errorf("%w: evidence_ref must be a safe logical reference", ErrInvalid)
}
command.EvidenceRef = &value
}
if command.Attributes == nil {
command.Attributes = map[string]any{}
}
data, err := json.Marshal(command)
if err != nil {
return nil, [32]byte{}, fmt.Errorf("normalize event: %w", err)
}
return data, sha256.Sum256(data), nil
}
func (s Service) Ingest(ctx context.Context, command Command) (Result, error) {
payload, digest, err := normalize(command)
if err != nil {
return Result{}, err
}
var normalized Command
if err := json.Unmarshal(payload, &normalized); err != nil {
return Result{}, err
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return Result{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
var eventID string
err = tx.QueryRow(ctx, `INSERT INTO bell_events(producer_id,source_event_id,event_type,occurred_at,location,severity,evidence_ref,normalized_payload,payload_sha256) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(producer_id,source_event_id) DO NOTHING RETURNING id::text`, normalized.ProducerID, normalized.SourceEventID, normalized.EventType, normalized.OccurredAt, normalized.Location, normalized.Severity, normalized.EvidenceRef, payload, digest[:]).Scan(&eventID)
created := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Result{}, err
}
if created {
if _, err = tx.Exec(ctx, `INSERT INTO bell_event_receipts(event_id,producer_id,source_event_id,payload_sha256) VALUES($1,$2,$3,$4)`, eventID, normalized.ProducerID, normalized.SourceEventID, digest[:]); err != nil {
return Result{}, err
}
}
result, storedDigest, err := loadResult(ctx, tx, normalized.ProducerID, normalized.SourceEventID)
if err != nil {
return Result{}, err
}
if !created && !equalDigest(storedDigest, digest[:]) {
return Result{}, ErrConflict
}
result.Duplicate = !created
if created && s.AfterPersist != nil {
if err := s.AfterPersist(ctx, tx, result.Event); err != nil {
return Result{}, fmt.Errorf("process accepted Event: %w", err)
}
}
if err = tx.Commit(ctx); err != nil {
return Result{}, err
}
return result, nil
}
func loadResult(ctx context.Context, q interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, producerID, sourceEventID string) (Result, []byte, error) {
var result Result
var digest []byte
err := q.QueryRow(ctx, `SELECT e.id::text,e.producer_id,e.source_event_id,e.event_type,e.occurred_at::text,e.location,e.severity,e.evidence_ref,e.normalized_payload,e.created_at::text,e.payload_sha256,r.id::text,r.accepted_at::text FROM bell_events e JOIN bell_event_receipts r ON r.event_id=e.id WHERE e.producer_id=$1 AND e.source_event_id=$2`, producerID, sourceEventID).Scan(&result.Event.ID, &result.Event.ProducerID, &result.Event.SourceEventID, &result.Event.EventType, &result.Event.OccurredAt, &result.Event.Location, &result.Event.Severity, &result.Event.EvidenceRef, &result.Event.NormalizedPayload, &result.Event.CreatedAt, &digest, &result.Receipt.ID, &result.Receipt.AcceptedAt)
result.Receipt.EventID = result.Event.ID
result.Receipt.ProducerID = producerID
result.Receipt.SourceEventID = sourceEventID
return result, digest, err
}
func equalDigest(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var different byte
for i := range a {
different |= a[i] ^ b[i]
}
return different == 0
}
func (s Service) List(ctx context.Context, limit int, before string) ([]Event, error) {
if limit < 1 || limit > 100 {
limit = 25
}
rows, err := s.DB.Query(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE ($2='' OR (created_at,id)<((SELECT created_at FROM bell_events WHERE id=nullif($2,'')::uuid),nullif($2,'')::uuid)) ORDER BY created_at DESC,id DESC LIMIT $1`, limit, before)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Event{}
for rows.Next() {
var item Event
if err := rows.Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Service) Get(ctx context.Context, id string) (Event, error) {
var item Event
err := s.DB.QueryRow(ctx, `SELECT id::text,producer_id,source_event_id,event_type,occurred_at::text,location,severity,evidence_ref,normalized_payload,created_at::text FROM bell_events WHERE id=$1`, id).Scan(&item.ID, &item.ProducerID, &item.SourceEventID, &item.EventType, &item.OccurredAt, &item.Location, &item.Severity, &item.EvidenceRef, &item.NormalizedPayload, &item.CreatedAt)
return item, err
}
+113
View File
@@ -0,0 +1,113 @@
package event
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
"github.com/jackc/pgx/v5"
)
func TestNormalizeRejectsUnsafeEvidence(t *testing.T) {
value := `file:C:\camera\secret.mp4`
_, _, err := normalize(Command{ProducerID: "test", SourceEventID: "1", EventType: "intrusion", OccurredAt: time.Now(), Location: "gate", Severity: "high", EvidenceRef: &value})
if !errors.Is(err, ErrInvalid) {
t.Fatalf("expected invalid evidence reference, got %v", err)
}
}
func TestPostgresIdempotencyAndImmutability(t *testing.T) {
url := os.Getenv("BELL_TEST_DATABASE_URL")
if url == "" {
t.Skip("BELL_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
db, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := migrations.Apply(ctx, db); err != nil {
t.Fatal(err)
}
service := Service{DB: db}
key := fmt.Sprintf("event-test-%d", time.Now().UnixNano())
command := Command{ProducerID: "integration-test", SourceEventID: key, EventType: "danger-zone", OccurredAt: time.Now().UTC().Truncate(time.Millisecond), Location: "test-zone", Severity: "high", Attributes: map[string]any{"track_id": "anonymous-1"}}
const count = 16
results := make(chan Result, count)
errorsFound := make(chan error, count)
var wg sync.WaitGroup
for range count {
wg.Add(1)
go func() {
defer wg.Done()
result, err := service.Ingest(ctx, command)
if err != nil {
errorsFound <- err
return
}
results <- result
}()
}
wg.Wait()
close(results)
close(errorsFound)
for err := range errorsFound {
t.Errorf("concurrent ingest: %v", err)
}
var eventID, receiptID string
seen := 0
for result := range results {
seen++
if eventID == "" {
eventID = result.Event.ID
receiptID = result.Receipt.ID
}
if result.Event.ID != eventID || result.Receipt.ID != receiptID {
t.Errorf("unstable identities: %#v", result)
}
}
if seen != count {
t.Fatalf("received %d results, want %d", seen, count)
}
conflict := command
conflict.Location = "different"
if _, err := service.Ingest(ctx, conflict); !errors.Is(err, ErrConflict) {
t.Fatalf("expected conflict, got %v", err)
}
if _, err := db.Exec(ctx, `UPDATE bell_events SET location='tampered' WHERE id=$1`, eventID); err == nil {
t.Fatal("immutable Event accepted update")
}
db.Close()
reopened, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
afterRestart, err := (Service{DB: reopened}).Ingest(ctx, command)
if err != nil {
t.Fatal(err)
}
if afterRestart.Event.ID != eventID || afterRestart.Receipt.ID != receiptID || !afterRestart.Duplicate {
t.Fatalf("receipt changed after restart: %#v", afterRestart)
}
failing := command
failing.SourceEventID = key + "-rollback"
failedService := Service{DB: reopened, AfterPersist: func(context.Context, pgx.Tx, Event) error { return errors.New("forced matching failure") }}
if _, err := failedService.Ingest(ctx, failing); err == nil {
t.Fatal("expected hook failure")
}
var persisted int
if err := reopened.QueryRow(ctx, `SELECT count(*) FROM bell_events WHERE producer_id=$1 AND source_event_id=$2`, failing.ProducerID, failing.SourceEventID).Scan(&persisted); err != nil {
t.Fatal(err)
}
if persisted != 0 {
t.Fatal("Event committed without atomic match")
}
}
+38
View File
@@ -0,0 +1,38 @@
package event
import (
"encoding/json"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/receipt"
)
type Command struct {
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidence_ref,omitempty"`
Attributes map[string]any `json:"attributes"`
}
type Event struct {
ID string `json:"id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventType string `json:"event_type"`
OccurredAt string `json:"occurred_at"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidence_ref,omitempty"`
NormalizedPayload json.RawMessage `json:"normalized_payload"`
CreatedAt string `json:"created_at"`
}
type Result struct {
Event Event `json:"event"`
Receipt receipt.Receipt `json:"receipt"`
Duplicate bool `json:"duplicate"`
}
+32
View File
@@ -0,0 +1,32 @@
package rbac
import (
"encoding/json"
"sort"
)
const (
DashboardRead = "dashboard:read"
AlertsRead = "alerts:read"
AlertsHandle = "alerts:handle"
EventsRead = "events:read"
RulesRead = "rules:read"
RulesWrite = "rules:write"
UsersRead = "users:read"
UsersWrite = "users:write"
AuditRead = "audit:read"
SyntheticWrite = "synthetic:write"
)
type Set map[string]struct{}
func (s Set) Has(permission string) bool { _, ok := s[permission]; return ok }
func (s Set) MarshalJSON() ([]byte, error) {
values := make([]string, 0, len(s))
for value := range s {
values = append(values, value)
}
sort.Strings(values)
return json.Marshal(values)
}
+9
View File
@@ -0,0 +1,9 @@
package receipt
type Receipt struct {
ID string `json:"id"`
EventID string `json:"event_id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
AcceptedAt string `json:"accepted_at"`
}
+72
View File
@@ -0,0 +1,72 @@
package rule
import (
"encoding/json"
"net/http"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
)
type HTTP struct {
Service Service
Auth auth.HTTP
Audit audit.Store
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/rules", h.Auth.Require(rbac.RulesRead, http.HandlerFunc(h.list)))
mux.Handle("POST /api/v1/rules", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.create)))
mux.Handle("PUT /api/v1/rules/{id}/enabled", h.Auth.Require(rbac.RulesWrite, http.HandlerFunc(h.setEnabled)))
}
func (h HTTP) list(w http.ResponseWriter, r *http.Request) {
items, err := h.Service.List(r.Context())
if err != nil {
writeJSON(w, 500, map[string]string{"error": "读取规则失败"})
return
}
writeJSON(w, 200, map[string]any{"items": items})
}
func (h HTTP) create(w http.ResponseWriter, r *http.Request) {
var input CreateInput
if err := decode(w, r, &input); err != nil {
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
return
}
item, err := h.Service.Create(r.Context(), input)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
actor := auth.Principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.created", "rule", &item.ID, "success", map[string]any{"version": item.Version})
writeJSON(w, 201, item)
}
func (h HTTP) setEnabled(w http.ResponseWriter, r *http.Request) {
var input struct {
Enabled bool `json:"enabled"`
}
if err := decode(w, r, &input); err != nil {
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
return
}
item, err := h.Service.SetEnabled(r.Context(), r.PathValue("id"), input.Enabled)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "规则不存在"})
return
}
actor := auth.Principal(r.Context())
_ = h.Audit.Record(r.Context(), &actor.ID, "rule.enabled_changed", "rule", &item.ID, "success", map[string]any{"enabled": item.Enabled, "version": item.Version})
writeJSON(w, 200, item)
}
func decode(w http.ResponseWriter, r *http.Request, value any) error {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
return decoder.Decode(value)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+132
View File
@@ -0,0 +1,132 @@
package rule
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrInvalid = errors.New("规则字段无效")
type Service struct{ DB *pgxpool.Pool }
func validSeverity(value string) bool {
return value == "low" || value == "medium" || value == "high" || value == "critical"
}
func severityRank(value string) int {
return map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}[value]
}
func (s Service) Create(ctx context.Context, input CreateInput) (Rule, error) {
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
if input.Code == "" || input.Name == "" || !validSeverity(input.MinimumSeverity) {
return Rule{}, ErrInvalid
}
if input.EventType != nil {
v := strings.TrimSpace(*input.EventType)
if v == "" {
input.EventType = nil
} else {
input.EventType = &v
}
}
if input.LocationContains != nil {
v := strings.TrimSpace(*input.LocationContains)
if v == "" {
input.LocationContains = nil
} else {
input.LocationContains = &v
}
}
var result Rule
err := s.DB.QueryRow(ctx, `INSERT INTO bell_rules(code,name,event_type,minimum_severity,location_contains) VALUES($1,$2,$3,$4,$5) RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, input.Code, input.Name, input.EventType, input.MinimumSeverity, input.LocationContains).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
return result, err
}
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool) (Rule, error) {
var result Rule
err := s.DB.QueryRow(ctx, `UPDATE bell_rules SET enabled=$2,version=version+1,updated_at=now() WHERE id=$1 RETURNING id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text`, id, enabled).Scan(&result.ID, &result.Code, &result.Name, &result.Enabled, &result.EventType, &result.MinimumSeverity, &result.LocationContains, &result.Version, &result.CreatedAt, &result.UpdatedAt)
return result, err
}
func (s Service) List(ctx context.Context) ([]Rule, error) {
rows, err := s.DB.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules ORDER BY created_at,id LIMIT 200`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Rule{}
for rows.Next() {
var item Rule
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Service) Match(ctx context.Context, tx pgx.Tx, evt event.Event) error {
rows, err := tx.Query(ctx, `SELECT id::text,code,name,enabled,event_type,minimum_severity,location_contains,version,created_at::text,updated_at::text FROM bell_rules WHERE enabled=true ORDER BY id`)
if err != nil {
return err
}
rules := []Rule{}
for rows.Next() {
var item Rule
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Enabled, &item.EventType, &item.MinimumSeverity, &item.LocationContains, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
rows.Close()
return err
}
rules = append(rules, item)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, current := range rules {
matched := true
reasons := []string{}
if current.EventType != nil && *current.EventType != evt.EventType {
matched = false
reasons = append(reasons, "事件类型不匹配")
}
if severityRank(evt.Severity) < severityRank(current.MinimumSeverity) {
matched = false
reasons = append(reasons, "风险等级低于阈值")
}
if current.LocationContains != nil && !strings.Contains(strings.ToLower(evt.Location), strings.ToLower(*current.LocationContains)) {
matched = false
reasons = append(reasons, "地点条件不匹配")
}
explanation := "全部条件命中"
if !matched {
explanation = strings.Join(reasons, ";")
}
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_evaluations(event_id,rule_id,rule_version,matched,explanation) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, evt.ID, current.ID, current.Version, matched, explanation); err != nil {
return err
}
if !matched {
continue
}
snapshot, _ := json.Marshal(current)
summary := fmt.Sprintf("%s:%s", current.Name, evt.EventType)
var alertID string
err := tx.QueryRow(ctx, `INSERT INTO bell_alerts(primary_rule_id,correlation_key,severity,summary,location) VALUES($1,$2,$3,$4,$5) ON CONFLICT(primary_rule_id,correlation_key) WHERE status='open' DO UPDATE SET updated_at=now(),severity=CASE WHEN array_position(ARRAY['low','medium','high','critical'],EXCLUDED.severity)>array_position(ARRAY['low','medium','high','critical'],bell_alerts.severity) THEN EXCLUDED.severity ELSE bell_alerts.severity END RETURNING id::text`, current.ID, strings.ToLower(evt.Location), evt.Severity, summary, evt.Location).Scan(&alertID)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO bell_alert_events(alert_id,event_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, alertID, evt.ID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO bell_rule_matches(alert_id,event_id,rule_id,rule_version,rule_snapshot,explanation) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, alertID, evt.ID, current.ID, current.Version, snapshot, explanation); err != nil {
return err
}
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package rule
import (
"context"
"fmt"
"os"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
func TestPostgresMatchingAndNavigationShape(t *testing.T) {
url := os.Getenv("BELL_TEST_DATABASE_URL")
if url == "" {
t.Skip("BELL_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
db, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := migrations.Apply(ctx, db); err != nil {
t.Fatal(err)
}
service := Service{DB: db}
prefix := fmt.Sprintf("rule-test-%d", time.Now().UnixNano())
eventType := "rule-test-danger"
location := "north"
first, err := service.Create(ctx, CreateInput{Code: prefix + "-one", Name: "规则一", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location})
if err != nil {
t.Fatal(err)
}
second, err := service.Create(ctx, CreateInput{Code: prefix + "-two", Name: "规则二", EventType: &eventType, MinimumSeverity: "low"})
if err != nil {
t.Fatal(err)
}
eventService := event.Service{DB: db, AfterPersist: service.Match}
base := event.Command{ProducerID: prefix, SourceEventID: "1", EventType: eventType, OccurredAt: time.Now().UTC(), Location: "north gate", Severity: "high", Attributes: map[string]any{}}
one, err := eventService.Ingest(ctx, base)
if err != nil {
t.Fatal(err)
}
base.SourceEventID = "2"
two, err := eventService.Ingest(ctx, base)
if err != nil {
t.Fatal(err)
}
var alertCount, linkCount int
if err := db.QueryRow(ctx, `SELECT count(DISTINCT m.alert_id),count(*) FROM bell_rule_matches m WHERE m.rule_id=ANY($1::uuid[])`, []string{first.ID, second.ID}).Scan(&alertCount, &linkCount); err != nil {
t.Fatal(err)
}
if alertCount != 2 || linkCount != 4 {
t.Fatalf("alerts=%d links=%d", alertCount, linkCount)
}
var shared int
if err := db.QueryRow(ctx, `SELECT count(*) FROM bell_alert_events a JOIN bell_alert_events b ON b.alert_id=a.alert_id JOIN bell_alerts current ON current.id=a.alert_id WHERE a.event_id=$1 AND b.event_id=$2 AND current.primary_rule_id=ANY($3::uuid[])`, one.Event.ID, two.Event.ID, []string{first.ID, second.ID}).Scan(&shared); err != nil {
t.Fatal(err)
}
if shared != 2 {
t.Fatalf("expected two correlated alerts, got %d", shared)
}
base.SourceEventID = "3"
base.EventType = "unmatched"
unmatched, err := eventService.Ingest(ctx, base)
if err != nil {
t.Fatal(err)
}
var evaluations, matches int
if err := db.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE matched) FROM bell_rule_evaluations WHERE event_id=$1`, unmatched.Event.ID).Scan(&evaluations, &matches); err != nil {
t.Fatal(err)
}
if evaluations < 2 || matches != 0 {
t.Fatalf("evaluations=%d matches=%d", evaluations, matches)
}
disabled, err := service.SetEnabled(ctx, first.ID, false)
if err != nil || disabled.Enabled || disabled.Version != 2 {
t.Fatalf("disable: %#v %v", disabled, err)
}
enabled, err := service.SetEnabled(ctx, first.ID, true)
if err != nil || !enabled.Enabled || enabled.Version != 3 {
t.Fatalf("enable: %#v %v", enabled, err)
}
}
+21
View File
@@ -0,0 +1,21 @@
package rule
type Rule struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
EventType *string `json:"event_type,omitempty"`
MinimumSeverity string `json:"minimum_severity"`
LocationContains *string `json:"location_contains,omitempty"`
Version int `json:"version"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type CreateInput struct {
Code string `json:"code"`
Name string `json:"name"`
EventType *string `json:"event_type,omitempty"`
MinimumSeverity string `json:"minimum_severity"`
LocationContains *string `json:"location_contains,omitempty"`
}
+66
View File
@@ -0,0 +1,66 @@
package synthetic
import (
"encoding/json"
"errors"
"net/http"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/app/rbac"
)
type HTTP struct {
Service Service
Auth auth.HTTP
}
func (h HTTP) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/synthetic/fixtures", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.fixtures)))
mux.Handle("POST /api/v1/synthetic/events", h.Auth.Require(rbac.SyntheticWrite, http.HandlerFunc(h.inject)))
}
func (h HTTP) fixtures(w http.ResponseWriter, r *http.Request) {
items, err := h.Service.Fixtures()
if err != nil {
writeJSON(w, 500, map[string]string{"error": "读取夹具失败"})
return
}
type summary struct {
Name string `json:"name"`
Label string `json:"label"`
ExpectedFailure bool `json:"expected_failure"`
}
result := make([]summary, 0, len(items))
for _, item := range items {
result = append(result, summary{Name: item.Name, Label: item.Label, ExpectedFailure: item.ValidationError != ""})
}
writeJSON(w, 200, map[string]any{"items": result})
}
func (h HTTP) inject(w http.ResponseWriter, r *http.Request) {
var input Input
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "请求格式无效"})
return
}
result, err := h.Service.Inject(r.Context(), input)
if err != nil {
status := 500
if errors.Is(err, ErrFixture) || errors.Is(err, event.ErrInvalid) {
status = 400
} else if errors.Is(err, event.ErrConflict) {
status = 409
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
status := 201
if result.Duplicate {
status = 200
}
writeJSON(w, status, result)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+33
View File
@@ -0,0 +1,33 @@
package synthetic
import (
"context"
"errors"
"strings"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
testevents "git.ilapage.cn/ila/yovision/Bell/server/testdata/events"
)
var ErrFixture = errors.New("合成夹具验证失败")
type Service struct{ Events event.Service }
type Input struct {
Fixture string `json:"fixture"`
SourceEventID string `json:"source_event_id,omitempty"`
}
func (s Service) Fixtures() ([]testevents.Fixture, error) { return testevents.All() }
func (s Service) Inject(ctx context.Context, input Input) (event.Result, error) {
fixture, err := testevents.Find(input.Fixture)
if err != nil {
return event.Result{}, err
}
if fixture.ValidationError != "" {
return event.Result{}, errors.Join(ErrFixture, errors.New(fixture.ValidationError))
}
if value := strings.TrimSpace(input.SourceEventID); value != "" {
fixture.Command.SourceEventID = value
}
return s.Events.Ingest(ctx, fixture.Command)
}
+51
View File
@@ -0,0 +1,51 @@
package synthetic
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
func TestInvalidFixturesAreStable(t *testing.T) {
service := Service{}
for _, name := range []string{"invalid-fields", "missing-evidence"} {
if _, err := service.Inject(context.Background(), Input{Fixture: name}); !errors.Is(err, ErrFixture) {
t.Fatalf("fixture %s: got %v", name, err)
}
}
}
func TestFixtureUsesEventService(t *testing.T) {
url := os.Getenv("BELL_TEST_DATABASE_URL")
if url == "" {
t.Skip("BELL_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
db, err := platform.OpenDatabase(ctx, url)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := migrations.Apply(ctx, db); err != nil {
t.Fatal(err)
}
service := Service{Events: event.Service{DB: db}}
source := fmt.Sprintf("synthetic-%d", time.Now().UnixNano())
first, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
if err != nil {
t.Fatal(err)
}
second, err := service.Inject(ctx, Input{Fixture: "danger-zone", SourceEventID: source})
if err != nil {
t.Fatal(err)
}
if first.Event.ID != second.Event.ID || !second.Duplicate {
t.Fatalf("fixture bypassed idempotency: %#v %#v", first, second)
}
}
+106
View File
@@ -0,0 +1,106 @@
package bell
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
alertLifecycle "git.ilapage.cn/ila/yovision/Bell/server/app/alert/lifecycle"
alertQuery "git.ilapage.cn/ila/yovision/Bell/server/app/alert/query"
"git.ilapage.cn/ila/yovision/Bell/server/app/audit"
"git.ilapage.cn/ila/yovision/Bell/server/app/auth"
"git.ilapage.cn/ila/yovision/Bell/server/app/event"
"git.ilapage.cn/ila/yovision/Bell/server/app/rule"
"git.ilapage.cn/ila/yovision/Bell/server/app/synthetic"
"git.ilapage.cn/ila/yovision/Bell/server/config"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
"git.ilapage.cn/ila/yovision/Bell/server/migrations"
)
const Version = "0.1.0"
func Run(ctx context.Context, args []string) error {
command := "serve"
if len(args) > 0 {
command = args[0]
}
if command == "version" {
fmt.Println(Version)
return nil
}
if command != "serve" && command != "migrate" && command != "create-admin" {
return fmt.Errorf("unknown command %q (use serve, migrate, create-admin, or version)", command)
}
cfg, err := config.Load()
if err != nil {
return err
}
db, err := platform.OpenDatabase(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
if err := migrations.Apply(ctx, db); err != nil {
return err
}
if command == "migrate" {
return nil
}
auditStore := audit.Store{DB: db}
authStore := auth.Store{DB: db}
authService := auth.Service{Store: authStore, Audit: auditStore, Secret: []byte(cfg.SessionSecret), SessionTTL: 8 * time.Hour}
if command == "create-admin" {
username, displayName := argument(args, "--username"), argument(args, "--display-name")
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
if displayName == "" {
displayName = username
}
if password == "" {
return fmt.Errorf("BELL_BOOTSTRAP_PASSWORD is required for create-admin")
}
return authService.BootstrapAdministrator(ctx, username, displayName, password)
}
app := platform.NewHTTPApp(db)
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
authHTTP.Register(app.Router())
ruleService := rule.Service{DB: db}
eventService := event.Service{DB: db, AfterPersist: ruleService.Match}
event.HTTP{Service: eventService, Auth: authHTTP}.Register(app.Router())
rule.HTTP{Service: ruleService, Auth: authHTTP, Audit: auditStore}.Register(app.Router())
alertQueryService := alertQuery.Service{DB: db}
alertQuery.HTTP{Service: alertQueryService, Auth: authHTTP}.Register(app.Router())
alertLifecycle.HTTP{Service: alertLifecycle.Service{DB: db, Audit: auditStore, Query: alertQueryService}, Auth: authHTTP}.Register(app.Router())
if cfg.Environment == "development" || cfg.Environment == "test" {
synthetic.HTTP{Service: synthetic.Service{Events: eventService}, Auth: authHTTP}.Register(app.Router())
}
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-serverCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
slog.Info("Bell listening", "address", cfg.HTTPAddress, "environment", cfg.Environment)
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func argument(args []string, name string) string {
for i := 1; i < len(args)-1; i++ {
if strings.EqualFold(args[i], name) {
return args[i+1]
}
}
return ""
}
+49
View File
@@ -0,0 +1,49 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Environment string
HTTPAddress string
DatabaseURL string
SessionSecret string
CookieSecure bool
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Environment: value("BELL_ENV", "development"),
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
SessionSecret: os.Getenv("BELL_SESSION_SECRET"),
ShutdownTimeout: 10 * time.Second,
}
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
seconds, err := strconv.Atoi(raw)
if err != nil || seconds < 1 || seconds > 300 {
return Config{}, fmt.Errorf("BELL_SHUTDOWN_SECONDS must be between 1 and 300")
}
cfg.ShutdownTimeout = time.Duration(seconds) * time.Second
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
}
if len(cfg.SessionSecret) < 32 {
return Config{}, fmt.Errorf("BELL_SESSION_SECRET must contain at least 32 characters")
}
cfg.CookieSecure = cfg.Environment == "production"
return cfg, nil
}
func value(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+16
View File
@@ -0,0 +1,16 @@
module git.ilapage.cn/ila/yovision/Bell/server
go 1.26.5
require (
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.37.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+30
View File
@@ -0,0 +1,30 @@
package platform
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse Bell database URL: %w", err)
}
poolConfig.MaxConns = 8
poolConfig.MinConns = 1
poolConfig.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("open Bell database: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping Bell database: %w", err)
}
return pool, nil
}
+50
View File
@@ -0,0 +1,50 @@
package platform
import (
"encoding/json"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type HTTPApp struct {
DB *pgxpool.Pool
Mux *http.ServeMux
}
func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
app := &HTTPApp{DB: db, Mux: http.NewServeMux()}
app.Mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "bell", "time": time.Now().UTC()})
})
app.Mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
return app
}
func (a *HTTPApp) Router() *http.ServeMux { return a.Mux }
func (a *HTTPApp) Handler() http.Handler {
return securityHeaders(a.Mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
@@ -0,0 +1,16 @@
package platform
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
recorder := httptest.NewRecorder()
h.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if recorder.Header().Get("X-Frame-Options") != "DENY" {
t.Fatal("missing frame protection")
}
}
+19
View File
@@ -0,0 +1,19 @@
// Bell is derived from the frozen go-admin baseline. See Bell/LICENSES/SOURCES.md.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"git.ilapage.cn/ila/yovision/Bell/server/cmd/bell"
)
func main() {
if err := bell.Run(context.Background(), os.Args[1:]); err != nil {
slog.Error("bell stopped", "error", err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS bell_runtime_probe (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
started_at timestamptz NOT NULL DEFAULT now()
);
@@ -0,0 +1,62 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE bell_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE CHECK (code IN ('administrator','operator','viewer')),
name text NOT NULL
);
CREATE TABLE bell_permissions (code text PRIMARY KEY, description text NOT NULL);
CREATE TABLE bell_role_permissions (
role_id uuid NOT NULL REFERENCES bell_roles(id),
permission_code text NOT NULL REFERENCES bell_permissions(code),
PRIMARY KEY (role_id, permission_code)
);
CREATE TABLE bell_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE CHECK (username = lower(username)),
display_name text NOT NULL,
password_hash text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE bell_user_roles (
user_id uuid NOT NULL REFERENCES bell_users(id),
role_id uuid NOT NULL REFERENCES bell_roles(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE bell_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES bell_users(id),
token_digest bytea NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
CHECK (expires_at > created_at)
);
CREATE INDEX bell_sessions_user_active_idx ON bell_sessions(user_id, expires_at) WHERE revoked_at IS NULL;
CREATE TABLE bell_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id uuid REFERENCES bell_users(id),
action text NOT NULL,
target_type text NOT NULL,
target_id text,
outcome text NOT NULL CHECK (outcome IN ('success','failure','denied')),
details jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE OR REPLACE FUNCTION bell_reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'bell audit facts are append-only'; END $$;
CREATE TRIGGER bell_audit_no_update BEFORE UPDATE OR DELETE ON bell_audit_log FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
INSERT INTO bell_roles(code,name) VALUES ('administrator','管理员'),('operator','处置员'),('viewer','只读用户');
INSERT INTO bell_permissions(code,description) VALUES
('dashboard:read','查看工作台'),('alerts:read','查看预警'),('alerts:handle','处置预警'),
('events:read','查看事件'),('rules:read','查看规则'),('rules:write','管理规则'),
('users:read','查看用户'),('users:write','管理用户与角色'),('audit:read','查看审计');
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r CROSS JOIN bell_permissions p WHERE r.code='administrator';
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','alerts:handle','events:read','rules:read') WHERE r.code='operator';
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT r.id,p.code FROM bell_roles r JOIN bell_permissions p ON p.code IN ('dashboard:read','alerts:read','events:read','rules:read') WHERE r.code='viewer';
@@ -0,0 +1,29 @@
CREATE TABLE bell_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
producer_id text NOT NULL,
source_event_id text NOT NULL,
event_type text NOT NULL,
occurred_at timestamptz NOT NULL,
location text NOT NULL,
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
evidence_ref text,
normalized_payload jsonb NOT NULL,
payload_sha256 bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (producer_id, source_event_id)
);
CREATE INDEX bell_events_created_page_idx ON bell_events(created_at DESC,id DESC);
CREATE TABLE bell_event_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL UNIQUE REFERENCES bell_events(id),
producer_id text NOT NULL,
source_event_id text NOT NULL,
payload_sha256 bytea NOT NULL,
accepted_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (producer_id, source_event_id)
);
CREATE OR REPLACE FUNCTION bell_reject_event_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'Bell Event and Receipt facts are immutable'; END $$;
CREATE TRIGGER bell_event_no_update BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
CREATE TRIGGER bell_receipt_no_update BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_event_mutation();
@@ -0,0 +1,3 @@
INSERT INTO bell_permissions(code,description) VALUES ('synthetic:write','注入开发测试合成事件');
INSERT INTO bell_role_permissions(role_id,permission_code)
SELECT id,'synthetic:write' FROM bell_roles WHERE code='administrator';
@@ -0,0 +1,51 @@
CREATE TABLE bell_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text NOT NULL UNIQUE,
name text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
event_type text,
minimum_severity text NOT NULL CHECK (minimum_severity IN ('low','medium','high','critical')),
location_contains text,
version integer NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE bell_alerts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
primary_rule_id uuid NOT NULL REFERENCES bell_rules(id),
correlation_key text NOT NULL,
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','acknowledged','closed')),
severity text NOT NULL CHECK (severity IN ('low','medium','high','critical')),
summary text NOT NULL,
location text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id,correlation_key) WHERE status='open';
CREATE INDEX bell_alerts_page_idx ON bell_alerts(created_at DESC,id DESC);
CREATE TABLE bell_alert_events (
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
event_id uuid NOT NULL REFERENCES bell_events(id),
linked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(alert_id,event_id)
);
CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id,alert_id);
CREATE TABLE bell_rule_matches (
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
event_id uuid NOT NULL REFERENCES bell_events(id),
rule_id uuid NOT NULL REFERENCES bell_rules(id),
rule_version integer NOT NULL,
rule_snapshot jsonb NOT NULL,
explanation text NOT NULL,
matched_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(event_id,rule_id)
);
CREATE TABLE bell_rule_evaluations (
event_id uuid NOT NULL REFERENCES bell_events(id),
rule_id uuid NOT NULL REFERENCES bell_rules(id),
rule_version integer NOT NULL,
matched boolean NOT NULL,
explanation text NOT NULL,
evaluated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(event_id,rule_id)
);
@@ -0,0 +1,18 @@
ALTER TABLE bell_alerts
ADD COLUMN acknowledged_by uuid REFERENCES bell_users(id),
ADD COLUMN acknowledged_at timestamptz,
ADD COLUMN closed_by uuid REFERENCES bell_users(id),
ADD COLUMN closed_at timestamptz,
ADD COLUMN close_outcome text CHECK (close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm')),
ADD COLUMN close_note text;
CREATE TABLE bell_alert_lifecycle_facts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
alert_id uuid NOT NULL REFERENCES bell_alerts(id),
transition text NOT NULL CHECK (transition IN ('acknowledged','closed')),
actor_user_id uuid NOT NULL REFERENCES bell_users(id),
occurred_at timestamptz NOT NULL DEFAULT now(),
details jsonb NOT NULL DEFAULT '{}'::jsonb,
UNIQUE(alert_id,transition)
);
CREATE INDEX bell_alert_lifecycle_timeline_idx ON bell_alert_lifecycle_facts(alert_id,id);
CREATE TRIGGER bell_alert_lifecycle_no_update BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_audit_mutation();
+67
View File
@@ -0,0 +1,67 @@
package migrations
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed *.sql
var files embed.FS
func Apply(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire migration connection: %w", err)
}
defer conn.Release()
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtext('yovision-bell-migrations'))`); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
defer func() {
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext('yovision-bell-migrations'))`)
}()
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := fs.ReadDir(files, ".")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || len(entry.Name()) < 4 || entry.Name()[len(entry.Name())-4:] != ".sql" {
continue
}
var applied bool
if err := conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
return err
}
if applied {
continue
}
sqlBytes, err := files.ReadFile(entry.Name())
if err != nil {
return err
}
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
if _, err = tx.Exec(ctx, string(sqlBytes)); err == nil {
_, err = tx.Exec(ctx, `INSERT INTO bell_schema_migrations(name) VALUES($1)`, entry.Name())
}
if err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
}
}
return nil
}
+1
View File
@@ -0,0 +1 @@
{"name":"danger-zone","label":"危险区域进入","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-danger-zone","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:00:00Z","location":"北侧围墙","severity":"high","evidence_ref":"evidence://synthetic/danger-zone","attributes":{"object":"anonymous-person","zone":"north-wall"}}}
+57
View File
@@ -0,0 +1,57 @@
package events
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
bellEvent "git.ilapage.cn/ila/yovision/Bell/server/app/event"
)
//go:embed *.json
var files embed.FS
type Fixture struct {
Name string `json:"name"`
Label string `json:"label"`
ValidationError string `json:"validation_error,omitempty"`
Command bellEvent.Command `json:"command"`
}
func All() ([]Fixture, error) {
entries, err := fs.ReadDir(files, ".")
if err != nil {
return nil, err
}
items := []Fixture{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := files.ReadFile(entry.Name())
if err != nil {
return nil, err
}
var item Fixture
if err := json.Unmarshal(data, &item); err != nil {
return nil, fmt.Errorf("decode fixture %s: %w", entry.Name(), err)
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return items, nil
}
func Find(name string) (Fixture, error) {
items, err := All()
if err != nil {
return Fixture{}, err
}
for _, item := range items {
if item.Name == name {
return item, nil
}
}
return Fixture{}, fmt.Errorf("unknown synthetic fixture %q", name)
}
+1
View File
@@ -0,0 +1 @@
{"name":"invalid-fields","label":"字段无效(预期失败)","validation_error":"夹具用于验证字段拒绝","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-invalid","event_type":"","occurred_at":"2026-08-12T09:02:00Z","location":"测试区域","severity":"unknown","attributes":{}}}
+1
View File
@@ -0,0 +1 @@
{"name":"line-crossing","label":"方向越线","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-line-crossing","event_type":"line-crossing","occurred_at":"2026-08-12T09:01:00Z","location":"宿舍楼东门","severity":"critical","evidence_ref":"evidence://synthetic/line-crossing","attributes":{"direction":"outside-to-inside","object":"anonymous-person"}}}
+1
View File
@@ -0,0 +1 @@
{"name":"missing-evidence","label":"证据缺失(预期失败)","validation_error":"此测试场景要求证据引用","command":{"producer_id":"bell-synthetic","source_event_id":"fixture-missing-evidence","event_type":"danger-zone-entry","occurred_at":"2026-08-12T09:03:00Z","location":"测试区域","severity":"high","attributes":{"requires_evidence":true}}}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
root: true,
env: { node: true },
extends: ['plugin:vue/vue3-essential', 'eslint:recommended'],
parserOptions: { parser: '@babel/eslint-parser', requireConfigFile: false },
rules: { 'vue/multi-word-component-names': 'off' }
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
+1
View File
@@ -0,0 +1 @@
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
+31
View File
@@ -0,0 +1,31 @@
{
"name": "yovision-bell-ui",
"version": "0.1.0",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vue-cli-service serve",
"lint": "eslint --ext .js,.vue src",
"build:prod": "vue-cli-service build"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"axios": "1.19.0",
"core-js": "3.50.0",
"element-plus": "2.14.4",
"vue": "3.5.41",
"vue-router": "4.6.4",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/eslint-parser": "7.29.7",
"@vue/cli-plugin-babel": "5.0.9",
"@vue/cli-plugin-eslint": "5.0.9",
"@vue/cli-service": "5.0.9",
"@vue/compiler-sfc": "3.5.41",
"eslint": "7.32.0",
"eslint-plugin-vue": "9.33.0"
},
"packageManager": "pnpm@9.15.1",
"engines": { "node": "22.22.1", "pnpm": "9.15.1" }
}
+8334
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bell 预警中心</title></head>
<body><noscript>Bell 需要启用 JavaScript。</noscript><div id="app"></div></body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import request from '../../bootstrap/request'
export const listAlerts = params => request.get('/api/v1/alerts', { params })
export const getAlert = id => request.get(`/api/v1/alerts/${id}`)
export const alertsForEvent = id => request.get(`/api/v1/events/${id}/alerts`)
export const acknowledgeAlert = id => request.post(`/api/v1/alerts/${id}/ack`)
export const closeAlert = (id, data) => request.post(`/api/v1/alerts/${id}/close`, data)
+9
View File
@@ -0,0 +1,9 @@
import request from '../../bootstrap/request'
export const login = data => request.post('/api/v1/auth/login', data)
export const logout = () => request.post('/api/v1/auth/logout')
export const currentUser = () => request.get('/api/v1/auth/me')
export const listUsers = () => request.get('/api/v1/users')
export const createUser = data => request.post('/api/v1/users', data)
export const replaceRoles = (id, roles) => request.put(`/api/v1/users/${id}/roles`, { roles })
export const listAudit = () => request.get('/api/v1/audit?limit=100')
+3
View File
@@ -0,0 +1,3 @@
import request from '../../bootstrap/request'
export const listEvents = params => request.get('/api/v1/events', { params })
export const getEvent = id => request.get(`/api/v1/events/${id}`)
+4
View File
@@ -0,0 +1,4 @@
import request from '../../bootstrap/request'
export const listRules = () => request.get('/api/v1/rules')
export const createRule = data => request.post('/api/v1/rules', data)
export const setRuleEnabled = (id, enabled) => request.put(`/api/v1/rules/${id}/enabled`, { enabled })
+3
View File
@@ -0,0 +1,3 @@
import request from '../../bootstrap/request'
export const listFixtures = () => request.get('/api/v1/synthetic/fixtures')
export const injectSynthetic = data => request.post('/api/v1/synthetic/events', data)
+6
View File
@@ -0,0 +1,6 @@
<template><router-view v-if="route.meta.public" /><AppLayout v-else><router-view /></AppLayout></template>
<script setup>
import { useRoute } from 'vue-router'
import AppLayout from '../layout/AppLayout.vue'
const route = useRoute()
</script>
+10
View File
@@ -0,0 +1,10 @@
<template>
<main class="page" tabindex="-1">
<header class="page__header"><div><p class="eyebrow">BELL</p><h1>预警工作台</h1><p>Bell 已独立运行,业务模块将按工单顺序启用。</p></div><el-tag type="success">服务就绪</el-tag></header>
<el-row :gutter="16">
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>待处理预警</template><strong class="metric">0</strong><p>规则模块启用后显示。</p></el-card></el-col>
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>今日事件</template><strong class="metric">0</strong><p>事件入站启用后显示。</p></el-card></el-col>
<el-col :xs="24" :sm="12" :lg="8"><el-card><template #header>运行状态</template><strong class="metric metric--small">独立</strong><p>无需 Sense 或 Brain 在线。</p></el-card></el-col>
</el-row>
</main>
</template>
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import store from './store'
import './theme.css'
createApp(App).use(store).use(router).use(ElementPlus).mount('#app')
+5
View File
@@ -0,0 +1,5 @@
import axios from 'axios'
const request = axios.create({ timeout: 10000, withCredentials: true, headers: { 'Content-Type': 'application/json' } })
request.interceptors.response.use(response => response.data, error => Promise.reject(error.response?.data || { error: '无法连接 Bell 服务' }))
export default request
+37
View File
@@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from './Dashboard.vue'
import Login from '../views/login/Login.vue'
import Users from '../views/system/Users.vue'
import Audit from '../views/system/Audit.vue'
import Events from '../views/event/Events.vue'
import SyntheticEvent from '../views/synthetic-event/SyntheticEvent.vue'
import Rules from '../views/rule/Rules.vue'
import Alerts from '../views/alert/list/Alerts.vue'
import { listFixtures } from '../api/synthetic'
import store from './store'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: Login, meta: { title: '登录', public: true } },
{ path: '/', name: 'dashboard', component: Dashboard, meta: { title: '工作台', permission: 'dashboard:read' } },
{ path: '/events', name: 'events', component: Events, meta: { title: '事件查询', permission: 'events:read' } },
{ path: '/alerts', name: 'alerts', component: Alerts, meta: { title: '预警管理', permission: 'alerts:read' } },
{ path: '/rules', name: 'rules', component: Rules, meta: { title: '规则配置', permission: 'rules:read' } },
{ path: '/development/synthetic-events', name: 'synthetic-events', component: SyntheticEvent, meta: { title: '合成事件测试', permission: 'synthetic:write', developmentOnly: true } },
{ path: '/system/users', name: 'users', component: Users, meta: { title: '用户与角色', permission: 'users:read' } },
{ path: '/system/audit', name: 'audit', component: Audit, meta: { title: '认证审计', permission: 'audit:read' } }
]
})
router.beforeEach(async to => {
if (!store.state.identity.checked) await store.dispatch('identity/restore')
const user = store.state.identity.user
if (to.meta.public) return user && to.name === 'login' ? { name: 'dashboard' } : true
if (!user) return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.permission && !user.permissions?.includes(to.meta.permission)) return { name: 'dashboard' }
if (to.meta.developmentOnly) { try { await listFixtures() } catch (_) { return { name: 'dashboard' } } }
return true
})
export default router
+4
View File
@@ -0,0 +1,4 @@
import { createStore } from 'vuex'
import identity from '../store/modules/identity'
export default createStore({ state: () => ({ product: 'Bell' }), modules: { identity } })
+26
View File
@@ -0,0 +1,26 @@
:root { --bell-accent:#409eff; --bell-bg:#f3f4f7; --bell-surface:#fff; --bell-text:#1f2329; --bell-muted:#6b7280; --bell-stroke:#e5e7eb; }
* { box-sizing:border-box; }
html, body, #app { min-height:100%; margin:0; }
body { font-family:"Segoe UI Variable","Microsoft YaHei",sans-serif; color:var(--bell-text); background:var(--bell-bg); }
.shell { min-height:100vh; display:grid; grid-template-columns:220px minmax(0,1fr); }
.sidebar { background:#263445; color:#fff; padding:16px 12px; }
.brand { display:flex; gap:12px; align-items:center; min-height:48px; padding:0 8px 16px; }
.brand__mark { display:grid; place-items:center; width:36px; height:36px; border-radius:8px; background:var(--bell-accent); font-weight:700; }
.brand small { display:block; color:#cbd5e1; margin-top:2px; }
.nav-item { display:flex; min-height:44px; align-items:center; padding:0 12px; color:#cbd5e1; text-decoration:none; border-radius:6px; }
.nav-item.router-link-active { color:#fff; background:#1f2d3d; }
.workspace { min-width:0; }
.navbar { height:50px; display:flex; align-items:center; justify-content:space-between; padding:0 20px; background:var(--bell-surface); border-bottom:1px solid var(--bell-stroke); }
.navbar__identity { color:var(--bell-muted); display:flex; align-items:center; gap:12px; }
.tags-view { height:34px; display:flex; align-items:end; padding:0 16px; background:#fff; border-bottom:1px solid var(--bell-stroke); }
.tag-current { padding:6px 12px; border:1px solid var(--bell-stroke); border-bottom:2px solid var(--bell-accent); }
.app-main { padding:20px; }
.page { max-width:1440px; margin:0 auto; outline:none; }
.page__header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; }
.page__header h1 { margin:0 0 6px; font-size:24px; }
.page__header p { margin:0; color:var(--bell-muted); }
.eyebrow { color:var(--bell-accent)!important; font-size:12px; font-weight:700; letter-spacing:.12em; }
.metric { font-size:36px; font-variant-numeric:tabular-nums; }
.metric--small { font-size:24px; }
@media (max-width:640px) { .shell { grid-template-columns:1fr; } .sidebar { position:static; padding:8px 12px; } .brand { padding-bottom:8px; } .sidebar nav { display:flex; } .workspace { min-width:0; } .app-main { padding:12px; } .page__header { flex-direction:column; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition-duration:.01ms!important; animation-duration:.01ms!important; } }
+28
View File
@@ -0,0 +1,28 @@
<template>
<div class="shell">
<aside class="sidebar" aria-label="主导航">
<div class="brand"><span class="brand__mark">B</span><div><strong>Bell</strong><small>预警中心</small></div></div>
<nav>
<router-link to="/" class="nav-item">工作台</router-link>
<router-link v-if="has('alerts:read')" to="/alerts" class="nav-item">预警管理</router-link>
<router-link v-if="has('events:read')" to="/events" class="nav-item">事件查询</router-link>
<router-link v-if="has('rules:read')" to="/rules" class="nav-item">规则配置</router-link>
<router-link v-if="has('users:read')" to="/system/users" class="nav-item">用户与角色</router-link>
<router-link v-if="has('audit:read')" to="/system/audit" class="nav-item">认证审计</router-link>
<router-link v-if="syntheticAvailable" to="/development/synthetic-events" class="nav-item">合成事件测试</router-link>
</nav>
</aside>
<section class="workspace">
<header class="navbar"><span>学校安全预警</span><div class="navbar__identity"><span>{{ user?.display_name }}</span><el-button link @click="signOut">退出</el-button></div></header>
<div class="tags-view"><span class="tag-current">{{ route.meta.title }}</span></div>
<div class="app-main"><slot /></div>
</section>
</div>
</template>
<script setup>
import { computed,onMounted,ref } from 'vue'
import { useRoute,useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { listFixtures } from '../api/synthetic'
const store=useStore();const route=useRoute();const router=useRouter();const user=computed(()=>store.state.identity.user);const syntheticAvailable=ref(false);const has=permission=>store.getters['identity/has'](permission);onMounted(async()=>{if(has('synthetic:write')){try{await listFixtures();syntheticAvailable.value=true}catch(_){syntheticAvailable.value=false}}});async function signOut(){await store.dispatch('identity/logout');await router.replace('/login')}
</script>
+1
View File
@@ -0,0 +1 @@
import './bootstrap/main'
+13
View File
@@ -0,0 +1,13 @@
import { currentUser, login, logout } from '../../api/auth'
export default {
namespaced: true,
state: () => ({ user: null, checked: false }),
getters: { has: state => permission => Boolean(state.user?.permissions?.includes(permission)) },
mutations: { setUser (state, user) { state.user = user; state.checked = true } },
actions: {
async restore ({ commit }) { try { commit('setUser', await currentUser()) } catch (_) { commit('setUser', null) } },
async login ({ commit }, form) { const result = await login(form); commit('setUser', result.user); return result.user },
async logout ({ commit }) { try { await logout() } finally { commit('setUser', null) } }
}
}
+26
View File
@@ -0,0 +1,26 @@
<template>
<main class="page" tabindex="-1">
<header class="page__header"><div><h1>预警管理</h1><p>先确认已看到并开始处理,再记录现场结果完成预警。</p></div></header>
<el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open">
<el-table-column prop="created_at" label="创建时间" min-width="180"/><el-table-column prop="summary" label="预警事项" min-width="190"/><el-table-column prop="location" label="地点" min-width="130"/>
<el-table-column label="状态"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{statusName(scope.row.status)}}</el-tag></template></el-table-column>
<el-table-column label="处理人" min-width="120"><template #default="scope">{{scope.row.acknowledged_by_name||'待确认'}}</template></el-table-column><el-table-column prop="event_count" label="关联事件" width="100"/>
<el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column>
</el-table>
<el-empty v-if="!loading&&!items.length" description="尚无预警"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div>
<el-drawer v-model="drawer" title="预警详情" size="min(640px, 100%)">
<template v-if="detail">
<el-alert v-if="actionError" :title="actionError" type="warning" show-icon :closable="false"/>
<el-descriptions :column="1" border><el-descriptions-item label="事项">{{detail.alert.summary}}</el-descriptions-item><el-descriptions-item label="地点">{{detail.alert.location}}</el-descriptions-item><el-descriptions-item label="规则">{{detail.alert.rule_name}}</el-descriptions-item><el-descriptions-item label="状态">{{statusName(detail.alert.status)}}</el-descriptions-item><el-descriptions-item label="处理人">{{detail.alert.acknowledged_by_name||'尚未开始处理'}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_outcome" label="现场结果">{{outcomeName(detail.alert.close_outcome)}}</el-descriptions-item><el-descriptions-item v-if="detail.alert.close_note" label="处理说明">{{detail.alert.close_note}}</el-descriptions-item></el-descriptions>
<div class="action-bar" v-if="canHandle"><el-button v-if="detail.alert.status==='open'" type="primary" :loading="acting" @click="ack">我已看到并开始处理</el-button><el-button v-if="canClose" type="primary" :loading="acting" @click="closeDialog=true">记录现场结果并完成</el-button></div>
<h2>关联事件</h2><el-table :data="detail.events" row-key="id"><el-table-column prop="occurred_at" label="发生时间"/><el-table-column prop="event_type" label="类型"/><el-table-column label="操作" width="80"><template #default="scope"><el-button link @click="router.push({name:'events',query:{event:scope.row.id}})">查看</el-button></template></el-table-column></el-table>
<h2>处理时间线</h2><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="entry.id" :timestamp="entry.occurred_at">{{entry.transition==='acknowledged'?'开始处理':'处理完成'}} · {{entry.actor_name}}</el-timeline-item><el-timeline-item v-for="match in detail.matches" :key="`${match.event_id}-${match.rule_id}`" :timestamp="match.matched_at" type="primary">规则 v{{match.rule_version}}:{{match.explanation}}</el-timeline-item></el-timeline>
</template>
</el-drawer>
<el-dialog v-model="closeDialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" @closed="resetClose"><el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false"/><el-form ref="closeFormRef" :model="closeForm" :rules="closeRules" label-position="top"><el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="closeForm.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item><el-form-item label="补充说明(可选)"><el-input v-model="closeForm.note" type="textarea" :rows="3" maxlength="500" show-word-limit/></el-form-item></el-form><template #footer><el-button @click="closeDialog=false">取消</el-button><el-button type="primary" :loading="acting" @click="finish">确认结果并完成</el-button></template></el-dialog>
</main>
</template>
<script setup>
import { computed,onMounted,reactive,ref } from 'vue';import { useRouter } from 'vue-router';import { useStore } from 'vuex';import { ElMessage } from 'element-plus';import { acknowledgeAlert,closeAlert,getAlert,listAlerts } from '../../../api/alert';const router=useRouter();const store=useStore();const items=ref([]);const loading=ref(false);const acting=ref(false);const next=ref('');const drawer=ref(false);const detail=ref(null);const actionError=ref('');const closeDialog=ref(false);const closeFormRef=ref();const closeForm=reactive({outcome:'',note:''});const closeRules={outcome:[{required:true,message:'请选择现场结果',trigger:'change'}]};const canHandle=computed(()=>store.getters['identity/has']('alerts:handle'));const currentUser=computed(()=>store.state.identity.user);const canClose=computed(()=>detail.value?.alert.status==='acknowledged'&&(detail.value.alert.acknowledged_by===currentUser.value?.id||currentUser.value?.roles?.includes('administrator')));const statusName=value=>({open:'待处理',acknowledged:'处理中',closed:'已完成'}[value]||value);const statusType=value=>({open:'danger',acknowledged:'warning',closed:'success'}[value]||'info');const outcomeName=value=>({danger_confirmed:'确认有危险',false_positive:'误报',site_normal:'现场正常',unable_to_confirm:'无法确认'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listAlerts({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取预警失败')}finally{loading.value=false}}async function open(row){try{detail.value=await getAlert(row.id);actionError.value='';drawer.value=true}catch(e){ElMessage.error(e.error||'读取预警失败')}}async function ack(){acting.value=true;actionError.value='';try{const result=await acknowledgeAlert(detail.value.alert.id);detail.value=result.detail;ElMessage.success(result.idempotent?'您已在处理此预警':'已记录由您开始处理');await load()}catch(e){if(e.current)detail.value=e.current;actionError.value=e.error||'确认失败'}finally{acting.value=false}}async function finish(){try{await closeFormRef.value.validate();acting.value=true;actionError.value='';const result=await closeAlert(detail.value.alert.id,closeForm);detail.value=result.detail;closeDialog.value=false;ElMessage.success(result.idempotent?'该结果已记录':'预警已完成');await load()}catch(e){if(e?.current)detail.value=e.current;if(e?.error)actionError.value=e.error}finally{acting.value=false}}function resetClose(){closeForm.outcome='';closeForm.note='';closeFormRef.value?.clearValidate()}onMounted(()=>load())
</script>
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}.action-bar{display:flex;gap:12px;margin-top:16px}h2{font-size:16px;margin-top:20px}.outcome-group{display:grid;gap:10px}</style>
+5
View File
@@ -0,0 +1,5 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>事件查询</h1><p>原始事件只读保存;处理结果不会改写事件。</p></div></header><el-table :data="items" v-loading="loading" row-key="id" @row-dblclick="open"><el-table-column prop="occurred_at" label="发生时间" min-width="180"/><el-table-column prop="event_type" label="事件类型" min-width="140"/><el-table-column prop="location" label="地点" min-width="150"/><el-table-column label="风险"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{severityName(scope.row.severity)}}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" @click.stop="open(scope.row)">详情</el-button></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无事件"/><div class="pagination-actions"><el-button :disabled="!next" @click="load(next)">下一页</el-button></div><el-drawer v-model="drawer" title="事件技术详情" size="min(560px, 100%)"><template v-if="selected"><el-descriptions :column="1" border><el-descriptions-item label="事件编号">{{selected.id}}</el-descriptions-item><el-descriptions-item label="来源">{{selected.producer_id}}</el-descriptions-item><el-descriptions-item label="来源事件编号">{{selected.source_event_id}}</el-descriptions-item><el-descriptions-item label="发生时间">{{selected.occurred_at}}</el-descriptions-item><el-descriptions-item label="证据引用">{{selected.evidence_ref||'无'}}</el-descriptions-item><el-descriptions-item label="规范化载荷"><pre>{{JSON.stringify(selected.normalized_payload,null,2)}}</pre></el-descriptions-item></el-descriptions><h2>关联预警</h2><el-table :data="selectedAlerts" row-key="id"><el-table-column prop="summary" label="事项"/><el-table-column label="操作" width="80"><template #default><el-button link @click="router.push('/alerts')">查看</el-button></template></el-table-column></el-table><el-empty v-if="!selectedAlerts.length" description="该事件未命中规则"/></template></el-drawer></main></template>
<script setup>
import { onMounted,ref } from 'vue';import { useRouter } from 'vue-router';import { ElMessage } from 'element-plus';import { alertsForEvent } from '../../api/alert';import { getEvent,listEvents } from '../../api/event';const router=useRouter();const items=ref([]);const loading=ref(false);const next=ref('');const drawer=ref(false);const selected=ref(null);const selectedAlerts=ref([]);const severityType=value=>({critical:'danger',high:'warning',medium:'primary',low:'info'}[value]||'info');const severityName=value=>({critical:'紧急',high:'高',medium:'中',low:'低'}[value]||value);async function load(before=''){loading.value=true;try{const result=await listEvents({limit:25,before});items.value=result.items;next.value=result.items.length===25?result.next:''}catch(e){ElMessage.error(e.error||'读取事件失败')}finally{loading.value=false}}async function open(row){try{selected.value=await getEvent(row.id);selectedAlerts.value=(await alertsForEvent(row.id)).items;drawer.value=true}catch(e){ElMessage.error(e.error||'读取事件失败')}}onMounted(()=>load())
</script>
<style scoped>.pagination-actions{display:flex;justify-content:flex-end;margin-top:16px}pre{white-space:pre-wrap;word-break:break-word;margin:0}h2{font-size:16px;margin-top:20px}</style>
+21
View File
@@ -0,0 +1,21 @@
<template>
<main class="login-page">
<el-card class="login-card">
<template #header><div><p class="eyebrow">BELL</p><h1>登录预警中心</h1><p>使用 Bell 独立账户。Sense 账户不能登录此系统。</p></div></template>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username" autocomplete="username" autofocus /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="full-button">登录 Bell</el-button>
</el-form>
</el-card>
</main>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
const store=useStore();const route=useRoute();const router=useRouter();const formRef=ref();const loading=ref(false);const error=ref('');const form=reactive({username:'',password:''});const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],password:[{required:true,message:'请输入密码',trigger:'blur'}]}
async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';await store.dispatch('identity/login',form);await router.replace(String(route.query.redirect||'/'))}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
</script>
<style scoped>.login-page{min-height:100vh;display:grid;place-items:center;padding:20px;background:#f3f4f7}.login-card{width:min(420px,100%)}h1{margin:0 0 8px}.full-button{width:100%;min-height:44px}</style>
+4
View File
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>规则配置</h1><p>启用规则会评估新 Event;版本与实际匹配快照永久保留。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">新建规则</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="name" label="规则名称" min-width="160"/><el-table-column prop="event_type" label="事件类型"><template #default="scope">{{scope.row.event_type||'全部'}}</template></el-table-column><el-table-column prop="minimum_severity" label="最低风险"/><el-table-column prop="location_contains" label="地点包含"><template #default="scope">{{scope.row.location_contains||'不限'}}</template></el-table-column><el-table-column prop="version" label="版本" width="80"/><el-table-column label="启用" width="100"><template #default="scope"><el-switch :model-value="scope.row.enabled" :disabled="!canWrite" :aria-label="`${scope.row.name}启用状态`" @change="value=>toggle(scope.row,value)"/></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚无规则"/><el-dialog v-model="dialog" title="新建预警规则" width="min(520px, calc(100vw - 32px))" @closed="reset"><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" placeholder="如 north-wall-danger"/></el-form-item><el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name"/></el-form-item><el-form-item label="事件类型(留空表示全部)"><el-input v-model.trim="form.event_type"/></el-form-item><el-form-item label="最低风险" prop="minimum_severity"><el-select v-model="form.minimum_severity" style="width:100%"><el-option label="低" value="low"/><el-option label="中" value="medium"/><el-option label="高" value="high"/><el-option label="紧急" value="critical"/></el-select></el-form-item><el-form-item label="地点包含(可选)"><el-input v-model.trim="form.location_contains"/></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建规则</el-button></template></el-dialog></main></template>
<script setup>
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createRule,listRules,setRuleEnabled } from '../../api/rule';const store=useStore();const canWrite=computed(()=>store.getters['identity/has']('rules:write'));const items=ref([]);const loading=ref(false);const saving=ref(false);const dialog=ref(false);const formRef=ref();const form=reactive({code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});const rules={code:[{required:true,message:'请输入规则编码',trigger:'blur'}],name:[{required:true,message:'请输入规则名称',trigger:'blur'}],minimum_severity:[{required:true,message:'请选择最低风险',trigger:'change'}]};async function load(){loading.value=true;try{items.value=(await listRules()).items}catch(e){ElMessage.error(e.error||'读取规则失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;const payload={...form,event_type:form.event_type||null,location_contains:form.location_contains||null};await createRule(payload);dialog.value=false;ElMessage.success('规则已创建');await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function toggle(item,value){try{await setRuleEnabled(item.id,value);ElMessage.success(value?'规则已启用':'规则已停用');await load()}catch(e){ElMessage.error(e.error||'更新失败')}}function reset(){Object.assign(form,{code:'',name:'',event_type:'',minimum_severity:'high',location_contains:''});formRef.value?.clearValidate()}onMounted(load)
</script>
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>合成事件测试</h1><p>仅开发测试环境可用;所有成功数据仍经过正式 Event/Receipt 服务。</p></div><el-tag type="warning">开发测试</el-tag></header><el-card><el-alert v-if="error" :title="error" type="error" show-icon :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit"><el-form-item label="测试场景" prop="fixture"><el-select v-model="form.fixture" style="width:100%"><el-option v-for="item in fixtures" :key="item.name" :label="item.label" :value="item.name"><span>{{item.label}}</span><el-tag v-if="item.expected_failure" type="info" size="small" style="margin-left:8px">预期失败</el-tag></el-option></el-select></el-form-item><el-form-item label="来源事件编号" prop="source_event_id"><el-input v-model.trim="form.source_event_id"/><small>保持编号不变可验证重复;换用另一夹具可验证冲突。</small></el-form-item><el-button type="primary" native-type="submit" :loading="loading">注入合成事件</el-button></el-form></el-card><el-card v-if="result" style="margin-top:16px"><template #header>接收结果</template><el-descriptions :column="1" border><el-descriptions-item label="结果">{{result.duplicate?'重复请求,返回原收据':'首次接收'}}</el-descriptions-item><el-descriptions-item label="Event ID">{{result.event.id}}</el-descriptions-item><el-descriptions-item label="Receipt ID">{{result.receipt.id}}</el-descriptions-item></el-descriptions></el-card></main></template>
<script setup>
import { onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { injectSynthetic,listFixtures } from '../../api/synthetic';const fixtures=ref([]);const formRef=ref();const loading=ref(false);const error=ref('');const result=ref(null);const form=reactive({fixture:'danger-zone',source_event_id:`ui-${Date.now()}`});const rules={fixture:[{required:true,message:'请选择场景',trigger:'change'}],source_event_id:[{required:true,message:'请输入来源事件编号',trigger:'blur'}]};onMounted(async()=>{try{fixtures.value=(await listFixtures()).items}catch(e){error.value=e.error||'测试入口不可用'}});async function submit(){if(loading.value)return;try{await formRef.value.validate();loading.value=true;error.value='';result.value=await injectSynthetic(form);ElMessage.success(result.value.duplicate?'已返回原收据':'合成事件已接收')}catch(e){if(e?.error)error.value=e.error}finally{loading.value=false}}
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>认证审计</h1><p>登录、退出和权限拒绝事实仅追加保存。</p></div></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="occurred_at" label="时间" min-width="180"/><el-table-column prop="action" label="动作" min-width="150"/><el-table-column prop="outcome" label="结果"><template #default="scope"><el-tag :type="scope.row.outcome==='success'?'success':'danger'">{{scope.row.outcome}}</el-tag></template></el-table-column><el-table-column prop="target_type" label="对象"/></el-table></main></template>
<script setup>
import { onMounted,ref } from 'vue';import { ElMessage } from 'element-plus';import { listAudit } from '../../api/auth';const items=ref([]);const loading=ref(false);onMounted(async()=>{loading.value=true;try{items.value=(await listAudit()).items}catch(e){ElMessage.error(e.error||'读取审计失败')}finally{loading.value=false}})
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><main class="page" tabindex="-1"><header class="page__header"><div><h1>用户与角色</h1><p>Bell 账户与 Sense 完全独立;按最小权限分配角色。</p></div><el-button v-if="canWrite" type="primary" @click="dialog=true">创建用户</el-button></header><el-table :data="items" v-loading="loading" row-key="id"><el-table-column prop="username" label="用户名"/><el-table-column prop="display_name" label="显示名称"/><el-table-column label="角色"><template #default="scope"><el-select v-if="canWrite" :model-value="scope.row.roles[0]" aria-label="角色" @change="role=>changeRole(scope.row,role)"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select><span v-else>{{roleName(scope.row.roles[0])}}</span></template></el-table-column><el-table-column label="状态"><template #default="scope"><el-tag :type="scope.row.enabled?'success':'info'">{{scope.row.enabled?'启用':'停用'}}</el-tag></template></el-table-column></el-table><el-empty v-if="!loading&&!items.length" description="尚未创建用户"/><el-dialog v-model="dialog" title="创建 Bell 用户" width="min(480px, calc(100vw - 32px))" @closed="reset"><el-alert title="不会生成默认密码;密码必须由管理员安全传递。" type="info" :closable="false"/><el-form ref="formRef" :model="form" :rules="rules" label-position="top"><el-form-item label="用户名" prop="username"><el-input v-model.trim="form.username"/></el-form-item><el-form-item label="显示名称" prop="display_name"><el-input v-model.trim="form.display_name"/></el-form-item><el-form-item label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password"/></el-form-item><el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width:100%"><el-option v-for="option in roleOptions" :key="option.value" v-bind="option"/></el-select></el-form-item></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建用户</el-button></template></el-dialog></main></template>
<script setup>
import { computed,onMounted,reactive,ref } from 'vue';import { ElMessage } from 'element-plus';import { useStore } from 'vuex';import { createUser,listUsers,replaceRoles } from '../../api/auth';const store=useStore();const items=ref([]);const loading=ref(false);const dialog=ref(false);const saving=ref(false);const formRef=ref();const form=reactive({username:'',display_name:'',password:'',role:'operator'});const canWrite=computed(()=>store.getters['identity/has']('users:write'));const roleOptions=[{value:'administrator',label:'管理员'},{value:'operator',label:'处置员'},{value:'viewer',label:'只读用户'}];const rules={username:[{required:true,message:'请输入用户名',trigger:'blur'}],display_name:[{required:true,message:'请输入显示名称',trigger:'blur'}],password:[{required:true,message:'请输入初始密码',trigger:'blur'},{min:12,message:'至少 12 个字符',trigger:'blur'}],role:[{required:true,message:'请选择角色',trigger:'change'}]};const roleName=value=>roleOptions.find(x=>x.value===value)?.label||value;async function load(){loading.value=true;try{items.value=(await listUsers()).items}catch(e){ElMessage.error(e.error||'读取用户失败')}finally{loading.value=false}}async function save(){try{await formRef.value.validate();saving.value=true;await createUser(form);ElMessage.success('用户已创建');dialog.value=false;await load()}catch(e){if(e?.error)ElMessage.error(e.error)}finally{saving.value=false}}async function changeRole(user,role){try{await replaceRoles(user.id,[role]);ElMessage.success('角色已更新');await load()}catch(e){ElMessage.error(e.error||'角色更新失败')}}function reset(){form.username='';form.display_name='';form.password='';form.role='operator';formRef.value?.clearValidate()}onMounted(load)
</script>
+10
View File
@@ -0,0 +1,10 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
outputDir: 'dist',
devServer: {
port: 8083,
proxy: { '/api': { target: process.env.BELL_API_ORIGIN || 'http://127.0.0.1:8082' } }
}
})
-9
View File
@@ -9,12 +9,3 @@ YoVision 是一个单仓多项目的智能视频事件平台,包含三个可
Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。
开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。
## 分支模型
- `explore`:2026-08-14 前实现的只读聚合快照,用于迁移对照和回退。
- `main`:用户审核通过的基线,禁止直接开发。
- `dev`:集成开发与测试分支;任务分支从 `dev` 创建并通过 PR 返回 `dev`。
- 只有用户明确审核通过,才能将 `dev` 合入 `main`。
Sense 与 Bell 的新实现必须从根 `goadmin-baseline.json` 冻结的 go-admin 和 go-admin-ui 完整提交派生,开发时必须核对对应 commit 的 go-admin-doc。仅参考 GoAdmin 的技术栈、布局或视觉不属于本项目认可的二次开发。
-3
View File
@@ -26,9 +26,6 @@
## GoAdmin 技术基线
- 初始化或升级 Sense 前必须读取根 `goadmin-baseline.json`,按其中的 go-admin、go-admin-ui commit 和 Go/Node/pnpm 精确版本实施。
- Sense 必须由冻结 go-admin 后端与 go-admin-ui 前端源码直接派生;旧 `explore` 实现只能用于需求和行为对照,禁止把其中自研 HTTP、认证、RBAC、会话或前端外壳复制为新基础。
- 开始每个骨架、认证权限、菜单路由、代码生成、ORM 或通用 UI 工单前,必须读取任务相关的冻结 go-admin-doc,并把参考主题/文件及采用方式写回工单。
- 骨架验收必须证明上游来源、继承路径、许可证、裁剪清单和相对上游差异;只有相似技术栈或视觉不能通过验收。
- `D:\github\goadmin` 下的仓库仅可只读参考;Sense 产品代码只能写入 `Sense/` 或工单授权路径,不得在参考仓库原地开发。
- 派生上游代码必须保留 MIT 许可证和版权声明;上游 `go-admin-doc` 只作框架用法参考,不覆盖 yovision Wiki 和本目录规则。
- 变更冻结基线必须升级为 Sense/Bell 协同工单;Sense 可以独立发布,但不得单独漂移上游或工具链版本。
+4 -24
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Project-Profile.-
wiki_revision: d32f00a86bb3127485b8ad4da8436bf2117352e0
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 5561227400539f0a3ba7713be16c171feb811fcb
synchronized_at: 2026-08-12T01:26:47Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -19,7 +19,7 @@ synchronized_at: 2026-08-14T01:06:22Z
| 首期客户场景 | 民办寄宿学校,默认 16 路高风险点位 |
| 首期规则 | 越线、危险区域、聚集等匿名安全规则;不启用人脸 |
| 产品形态 | Sense 与 Bell 两个独立销售产品,Brain 为独立推理交付单元 |
| 当前阶段 | 进入 GoAdmin 源码派生重建:旧实现归档于 `explore`,`main` 为审核基线,`dev` 为集成开发分支 |
| 当前阶段 | 新仓库文档与开发流程初始化,业务代码尚未迁移 |
| 历史来源 | `D:\OPC\yovision_old`,只读追溯 |
## 子项目与交付单元
@@ -42,7 +42,7 @@ synchronized_at: 2026-08-14T01:06:22Z
- 证据:客户侧 MinIO/S3 兼容对象存储;常态录像优先留在客户已有 NVR。
- 首期验证平台:NVIDIA x86/Jetson;M1-M3 不承诺 GB/T 28181、信创或原生 App。
2026-08-14 起,原 Sense、Bell 实现只在 `explore` 和原功能分支中作为迁移参考,不再作为新开发基础。新的 Sense、Bell 必须从下述冻结 go-admin/go-admin-ui 完整提交派生;实施时必须核对冻结 go-admin-doc。Brain 尚未初始化。
业务代码尚未初始化;Sense/Bell 的 Go/Node 基线已冻结并记录于下文,Brain 的 Python/CUDA 版本仍须在对应骨架工单中冻结。
## 阅读入口
@@ -92,23 +92,3 @@ Sense、Bell 共用的可复现技术基线记录在仓库根 `goadmin-baseline.
固定工具链为 Go 1.26.5、Node.js 22.22.1、pnpm 9.15.1。go-admin-ui 的 `package.json` version 仍为 2.0.9,但冻结提交位于 `v2.0.9` 后 170 个提交,因此不得只用“2.0.9”代表当前基线。
三个上游仓库均使用 MIT 许可证。派生代码必须保留许可证和版权声明。Sense、Bell 使用同一上游基线,但分别维护代码、配置、构建、测试、版本和发布。任何上游或工具链升级必须建立影响 Sense/Bell 的协同工单。
<!-- sense-runtime:start -->
## Sense 重建状态
当前可运行的旧 Sense 实现保存在 `explore`,不进入新 `main` / `dev` 基线。新 Sense 尚未初始化;后续必须从冻结的 go-admin 后端和 go-admin-ui 前端源码派生,保留上游骨架、许可证和来源证据,并在工单记录实际参考的 go-admin-doc 页面。
<!-- sense-runtime:end -->
<!-- bell-runtime:start -->
## Bell 重建状态
当前 Bell MVP 实现在 `explore` 和原功能分支中保留,只作需求、行为和迁移对照。新 Bell 尚未初始化;后续必须从与 Sense 相同的冻结 go-admin/go-admin-ui 基线独立派生,不能复制旧自研基础框架或共享 Sense 的认证与数据库。
<!-- bell-runtime:end -->
## 分支治理
- `explore`:2026-08-14 前实现的只读聚合快照;不接受新功能。
- `main`:用户审核通过的最小/发布基线;禁止直接开发和未经用户明确审核的合并。
- `dev`:集成开发与测试分支;功能分支从 `dev` 派生,并通过 PR 合回 `dev`。
- 只有用户明确审核通过,才能把 `dev` 合入 `main`。
+9 -36
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Development-Workflow.-
wiki_revision: 2bb60b79073439babfda05140ce813d197de81d9
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 44720127496ee08289db439268708d6314705faf
synchronized_at: 2026-08-12T00:46:39Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
@@ -241,19 +241,14 @@ python dev_scripts/new_task_archive.py 123 "修复登录超时"
## go-admin / go-admin-ui 开发约束
Sense、Bell 使用 go-admin 和 go-admin-ui 时,采用以下统一流程。“基于 GoAdmin”必须是源码派生,不得解释为仅参考技术栈、目录、页面风格或交互:
Sense、Bell 使用 go-admin 和 go-admin-ui 时,采用以下统一流程:
1. 初始化时从 `goadmin-baseline.json` 指定的 go-admin、go-admin-ui 完整 commit 导入/派生产品源码,保留 MIT 许可证、版权和来源清单;不得以自建 `net/http`、自建认证/RBAC 或仅使用 Vue + Element Plus 替代上游骨架。
2. 实施工单开始前必须核对冻结 go-admin-doc,并在工单证据中记录参考的文档主题或文件;文档只解释上游用法,不改变本项目需求事实源。
在满足上述来源门禁后:
3. 先列出产品实际需要的菜单、路由、权限和接口,只呈现必要能力,框架演示页和无关管理模块不进入产品导航。
4. 快速交付阶段优先使用配置、菜单权限或路由开关隐藏模块。隐藏只代表用户看不到入口;涉及权限或安全时,还必须禁用对应后端访问能力并验证直接请求不可用。
5. 永久删除默认模块必须单独建立清理工单,核对代码依赖、数据库对象、权限记录、构建、框架升级和回退方式,不能混入普通业务页面工单。
6. 新页面先盘点并复用已有布局、表格、表单、弹窗、上传、权限控制和请求封装。只有现有组件无法满足明确需求时才新增组件,并在工单记录复用缺口。
7. 新组件沿用所属产品现有的颜色、字号、间距、状态反馈、交互方式、命名和目录结构,不建立独立于整体界面的视觉体系。
8. 单页需求不得直接改变底层公共组件的全局行为。确需全局修改时单独建单,列出受影响页面、兼容方式和回归测试;同时影响 Sense、Bell 时按协同工单处理。
1. 先列出产品实际需要的菜单、路由、权限和接口,只呈现必要能力,框架演示页和无关管理模块不进入产品导航。
2. 快速交付阶段优先使用配置、菜单权限或路由开关隐藏模块。隐藏只代表用户看不到入口;涉及权限或安全时,还必须禁用对应后端访问能力并验证直接请求不可用。
3. 永久删除默认模块必须单独建立清理工单,核对代码依赖、数据库对象、权限记录、构建、框架升级和回退方式,不能混入普通业务页面工单。
4. 新页面先盘点并复用已有布局、表格、表单、弹窗、上传、权限控制和请求封装。只有现有组件无法满足明确需求时才新增组件,并在工单记录复用缺口。
5. 新组件沿用所属产品现有的颜色、字号、间距、状态反馈、交互方式、命名和目录结构,不建立独立于整体界面的视觉体系。
6. 单页需求不得直接改变底层公共组件的全局行为。确需全局修改时单独建单,列出受影响页面、兼容方式和回归测试;同时影响 Sense、Bell 时按协同工单处理。
验收至少检查:没有多余入口、没有可见但不可用的失效入口、没有重复实现已有组件,且新增页面与同类页面的视觉和交互一致。
@@ -262,25 +257,3 @@ Sense、Bell 使用 go-admin 和 go-admin-ui 时,采用以下统一流程。
- 隐藏:保留代码与能力,只移除普通用户入口,便于快速交付和以后恢复。
- 禁用:前端入口与后端访问能力均不可用,适用于权限、安全或明确停用的能力。
- 删除:清理代码、依赖、数据对象和权限记录,风险较高,必须由独立清理工单实施。
## 分支模型与审核门禁
```text
explore(旧实现只读快照)
dev
├── feature/<issue>-sense-...
├── feature/<issue>-brain-...
├── feature/<issue>-bell-...
└── coordination/<issue>-...
│
└── PR + 测试 → dev → 用户明确审核 → main
```
- `explore` 仅供对照、迁移和回退,不继续开发。
- `main` 只接收用户明确审核通过的 `dev` 结果;禁止直接在 `main` 修改、提交或推送。
- 新任务分支必须从当前 `dev` 创建,实施完成后 PR 目标为 `dev`。
- Agent 的“待验收”不等于允许进入 `main`;只有用户明确审核/验收通过才可执行 `dev → main`。
- 紧急修复也必须建单并取得用户对分支与合并路径的明确授权,不默认绕过 `dev`。
+5 -24
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: 119fb7b2a994d54e4d0a40f7bc2ef6238b162387
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 04b5eaea579729e427f1b2b8962399e861d5eb53
synchronized_at: 2026-08-11T10:30:34Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -31,15 +31,15 @@ Bell 也可接收合成事件、传感器平台或第三方系统事件;Sense
| 能力 | 路径 | 首个阅读入口 | 验证位置 | 风险 |
|---|---|---|---|---|
| Sense 产品 | `Sense/` | `Sense/AGENTS.md`;待 GoAdmin 派生工单建立 README/入口 | 待新骨架建立 | 高:GoAdmin 派生、设备、媒体、凭据 |
| Sense 产品 | `Sense/` | `Sense/AGENTS.md`;后续 README/入口 | `Sense/` 内测试 | 高:设备、媒体、凭据、并发对账 |
| Brain 推理 | `Brain/` | `Brain/AGENTS.md`;后续包入口 | `Brain/` 内测试与契约测试 | 高:模型、GPU、隐私、事件语义 |
| Bell 产品 | `Bell/` | `Bell/AGENTS.md`;待 GoAdmin 派生工单建立 README/入口 | 待新骨架建立 | 高:GoAdmin 派生、认证、事件与告警状态机 |
| Bell 产品 | `Bell/` | `Bell/AGENTS.md`;后续 README/入口 | `Bell/` 内测试 | 高:认证、事件、告警状态机、通知 |
| 共享契约 | `contracts/` | `contracts/AGENTS.md` | 三端消费者/生产者测试 | 高:兼容性与跨项目影响 |
| Harness | `dev_scripts/` | `check_harness.py` | `tests/` | 中 |
| 工单模板 | `.gitea/issue_template/` | `task.md` | Harness 严格检查 | 中 |
| Wiki 镜像 | `docs/` | `docs/README.md` | `sync_wiki_docs.py --check` | 低;禁止直接编辑 |
旧 Sense、Bell 入口仅存在于 `explore` 快照,不是 `main` / `dev` 当前代码地图。三个项目的新入口必须随新骨架工单建立;不得把旧自研基础框架复制回 `dev`。
业务代码尚未迁入;具体函数、路由和测试入口必须随各项目骨架工单更新,不复制旧仓库代码地图。
## 两条主要执行路径
@@ -79,22 +79,3 @@ Sense/Brain 生成事件
- 业务预警与运维告警分开。
- Area/设备/规则在各独立产品内有自己的业务投影,不以共享数据库同步。
- 单项目工单不得顺带修改其他项目;共享契约变更必须由协调工单统筹。
<!-- sense-runtime:start -->
## Sense 新代码入口
Sense 当前处于重建前的空基线。新入口必须由 GoAdmin 派生工单从冻结 go-admin/go-admin-ui 源码建立,并在来源清单记录上游 commit;不得沿用 `explore` 中自研的 HTTP、认证、RBAC 或前端外壳作为基础框架。
<!-- sense-runtime:end -->
<!-- sense-mvp:start -->
## Sense 旧 MVP 对照
旧设备、ONVIF/RTSP、MediaMTX、实时监看和区域配置实现保存在 `explore`。迁移时只提取已确认业务需求和必要业务适配器;通用认证、权限、菜单、请求封装和管理端外壳必须优先使用 GoAdmin/go-admin-ui。
<!-- sense-mvp:end -->
<!-- bell-mvp:start -->
## Bell 旧 MVP 对照
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
<!-- bell-mvp:end -->
+2 -21
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: 85ddfe99c2f9c97b18d5e39849a50b8c5fca276e
synchronized_at: 2026-08-14T01:17:28Z
wiki_revision: 7a9645a7ca7f4b3478d27afb2d441238431f501e
synchronized_at: 2026-08-11T10:30:38Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -66,22 +66,3 @@ synchronized_at: 2026-08-14T01:17:28Z
- 三个交付单元的正式版本与发布流程。
未确认项不得从旧代码或模板默认值推断为当前事实。
<!-- sense-mvp:start -->
## Sense 旧 MVP 规则状态
2026-08-14 前的 Sense 账户、设备、发现、Profile、MediaMTX、播放会话和区域版本实现规则仅适用于 `explore` 快照,不是新 `main` / `dev` 的运行事实。迁移工单应从这些记录提取已确认业务与安全约束,但认证、RBAC、管理端外壳和通用 CRUD 必须基于冻结 GoAdmin 源码重新实现并重新验收。
<!-- sense-mvp:end -->
<!-- bell-mvp:start -->
## Bell 旧 MVP 规则状态
2026-08-14 前的 Bell 独立身份、Event/Receipt、规则、Alert、ack/close 和审计实现仅保存在 `explore`。不可变、幂等、并发处置和产品独立边界仍是待迁移业务要求,但新实现必须从冻结 GoAdmin/go-admin-ui 源码派生并重新验收。
<!-- bell-mvp:end -->
## 旧实现迁移边界
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
+6 -36
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: 1730ec258fb54c1b4be15ce0f6e1a18e44281810
synchronized_at: 2026-08-14T01:06:22Z
wiki_revision: 1eb27d082ebf892343c9a5eed6f9363363234903
synchronized_at: 2026-08-12T01:26:57Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -59,7 +59,7 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
|---|---|---|---|---|
| Sense | 待骨架工单冻结 | 待冻结 | 待冻结 | 未初始化业务代码 |
| Brain | 待骨架工单冻结 | 待冻结 | 待冻结 | 未初始化业务代码 |
| Bell | 待 GoAdmin 派生工单建立 | 待建立 | 待建立 | 旧实现仅在 `explore`;新基线未初始化 |
| Bell | 待骨架工单冻结 | 待冻结 | 待冻结 | 未初始化业务代码 |
不得复制旧仓库命令来填空。每个骨架工单必须同时建立 README、可复制命令和最小测试。
@@ -73,8 +73,8 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
## 测试数据与日志
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。ONVIF 自动化测试需覆盖 Basic、Digest challenge、Media 服务发现、跨主机地址归一化、拒绝地址凭据和禁止重定向。
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据;真实设备验证只记录状态与 Profile 数量,不记录设备地址、Authorization 或 Stream URI。
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据。
- 日志必须可按 request/event/alert ID 追踪,但不得记录 Authorization、Cookie 或连接密钥。
## 完成修改前
@@ -107,36 +107,6 @@ pnpm --version
`D:\github\goadmin` 下三个仓库只允许读取和核对,不允许在其中开发或提交 yovision 产品代码。本机路径不是跨机器事实;缺少该路径时,应从清单中的上游 URL 检出相同 commit 到仓库外目录。
截至 2026-08-12,系统 Go 1.23.0 仍不满足基线;开发机已在仓库外安装并校验隔离 Go 1.26.5,并通过 Corepack 使用 pnpm 9.15.1。Sense、Bell 的后端测试/构建与前端 lint/build 已在该隔离工具链完成。隔离目录是开发机临时事实,不得写入部署配置;其他机器应从官方来源安装相同精确版本。
截至 2026-08-12 的本机核对结果:Go 1.23.0,不满足 go-admin `go.mod` 声明的 Go 1.26.5;Node.js 22.22.1 符合基线;pnpm 尚未安装,Corepack 0.34.6 可用。因此后端构建和 pnpm 前端构建均未完成验证。安装隔离的 Go 1.26.5 并通过 Corepack 启用 pnpm 9.15.1 后,才可执行后续骨架构建验收。
`go-admin-doc` 只用于理解上游框架;项目自己的架构、开发规则和版本事实仍以 yovision Wiki、Agent 规则与 `goadmin-baseline.json` 为准。
<!-- sense-runtime:start -->
## Sense 本地构建与验证
新 Sense 尚未初始化。骨架工单必须先验证冻结 go-admin/go-admin-ui commit 与许可证,再记录从上游继承的构建、迁移、启动和测试命令;不得把 `explore` 的自研命令写成新基线事实。
<!-- sense-runtime:end -->
<!-- bell-runtime:start -->
## Bell 本地构建与验证
新 Bell 尚未初始化。骨架工单必须从冻结 GoAdmin 源码建立并验证独立构建、数据库、认证和前端流程;旧 Bell 命令只在 `explore` 对应提交中适用。
<!-- bell-runtime:end -->
<!-- sense-windows-package:start -->
## Sense Windows 打包状态
旧 Sense Windows 包脚本只属于 `explore` 快照。新的打包命令必须在 GoAdmin 派生骨架和业务迁移完成后由独立工单重新建立、验证和记录。
<!-- sense-windows-package:end -->
## GoAdmin 派生前置检查
每个 Sense/Bell 骨架或基础能力工单在修改前必须:
1. 核对 `goadmin-baseline.json` 中三个完整 commit。
2. 核对本机只读副本或重新检出的上游仓库 HEAD。
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
+2 -23
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: 2be5d803b954df64e31085bcf097ee27f8611f9d
synchronized_at: 2026-08-14T01:17:28Z
wiki_revision: 632e07b6cd22f6e3ae07c23ed58439ea2beecff2
synchronized_at: 2026-08-11T10:30:46Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -39,24 +39,3 @@ synchronized_at: 2026-08-14T01:17:28Z
- 契约破坏性变化没有新版本、迁移和回退方案;
- 涉及删除、不可逆迁移、发布或生产变更但没有明确授权;
- 工作区出现来源不明且与当前任务重叠的修改。
<!-- sense-mvp:start -->
## 旧 Sense MVP 排错资料
旧 Sense 的数据库、ONVIF/RTSP、MediaMTX、实时监看、会话和区域排错只适用于 `explore` 快照。新 `main` / `dev` 当前没有可运行 Sense;在新的 GoAdmin 派生骨架和业务迁移工单完成前,不使用旧命令或旧状态判断新实现。
<!-- sense-mvp:end -->
<!-- sense-windows-package:start -->
## 旧 Sense Windows 包状态
旧 Windows 打包与启动脚本仅保存在 `explore`,不属于新开发基线。新的 GoAdmin 派生实现必须由独立工单重新建立打包、配置和排错说明。
<!-- sense-windows-package:end -->
## 重建阶段常见问题
| 现象 | 处理 |
|---|---|
| 在 `dev` 找不到 Sense/Bell 可运行代码 | 这是重建空基线的预期状态;旧实现位于 `explore`,新代码必须由 GoAdmin 源码派生工单建立。 |
| 新骨架只有相似页面、没有 GoAdmin 启动链或权限模块 | 不符合二次开发门禁;停止验收,对照 `goadmin-baseline.json`、上游源码和 go-admin-doc 重新实施。 |
+2 -26
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Documentation-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
wiki_revision: d4f2bfcef46493361ba019839b36957ed9947003
synchronized_at: 2026-08-14T01:18:58Z
wiki_revision: e0b43bfada515ff95831debc4378de41a8ad969a
synchronized_at: 2026-08-11T10:31:06Z
<!-- gitea-wiki-mirror:end -->
# 交付文档指南
@@ -78,27 +78,3 @@ synchronized_at: 2026-08-14T01:18:58Z
## 不在本页解决的内容
开发任务怎样建单、实施和归档见[开发工作流](Development-Workflow.-);代码结构和维护入口见[架构与代码地图](Architecture-and-Code-Map.-)。本页不规定市场宣传、合同、法务或商务承诺。
<!-- sense-mvp:start -->
## Sense MVP 岗位操作路径
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名;仍应由管理员选择难猜且不复用的密码。
- 实施/运维:添加设备、只写更新凭据、在获准网卡发现或手工接入、检查主辅码流、处理媒体进程与对账。
- 站点管理员:查看/维护站点设备,实时监看,绘制和发布区域版本;无权创建系统管理员。
- 只读用户:查看设备、媒体状态、实时画面和区域,不能修改。
交付验收必须使用仓库外测试账户和获准实验室设备;文档、截图、工单和日志不得包含摄像机密码、Cookie、令牌、含凭据 URI 或客户真实画面。当前自动化已覆盖内部状态和安全边界;真实 PostgreSQL、MediaMTX、摄像机兼容与目标浏览器画面仍需实施人员验证。
<!-- sense-mvp:end -->
<!-- sense-windows-package:start -->
## Sense Windows 运行包交付
交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。
- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。
- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
<!-- sense-windows-package:end -->
@@ -1,86 +0,0 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-58-建立explore-main-dev分支治理并重置GoAdmin开发基线
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-58-%E5%BB%BA%E7%AB%8Bexplore-main-dev%E5%88%86%E6%94%AF%E6%B2%BB%E7%90%86%E5%B9%B6%E9%87%8D%E7%BD%AEGoAdmin%E5%BC%80%E5%8F%91%E5%9F%BA%E7%BA%BF.-
wiki_revision: 67ec6d59eb55795e226d0a38cac57b3188d8fa52
synchronized_at: 2026-08-14T01:14:45Z
<!-- gitea-wiki-mirror:end -->
# 58 建立 explore/main/dev 分支治理并重置 GoAdmin 开发基线
- 类型:根级协调任务
- 状态:待验收
- 日期:2026-08-14
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/58
## 背景与目标
用户确认保留当前实现作为历史快照,并将后续开发切换到 `dev`,只有用户审核后才能进入 `main`。同时纠正此前“仅参考 GoAdmin、自研基础框架”的偏差:新的 Sense、Bell 必须真正从冻结 go-admin 与 go-admin-ui 源码派生,开发时必须参考冻结 go-admin-doc。
## 最终方案
1. 创建 `explore` 聚合快照,合入 Sense 最新链、Bell MVP、Bell 任务归档和已确认原型;原来源分支全部保留。
2. 共享 Wiki 镜像冲突时,快照树保留 Sense 最新版本;Bell 独立任务归档保留在 `docs/task/`,Bell 共享文档仍可从来源提交完整读取。
3. 审计发现执行前 `main` 已经是最小治理基线,没有 Sense/Bell 产品实现,因此不制造删除或清空提交;只补充分支门禁、GoAdmin 源码派生门禁和更新后的 Wiki 镜像。
4. `dev` 从最终 `main` 创建,后续任务分支从 `dev` 派生,通过 PR 合回 `dev`;只有用户明确审核通过才能执行 `dev → main`。
5. “基于 GoAdmin”定义为从 `goadmin-baseline.json` 指定的完整 go-admin/go-admin-ui commit 导入或派生源码,保留许可证、启动链和通用框架能力。仅使用 Go、Vue、Element Plus、类似目录或视觉不算满足。
6. 每个 Sense/Bell 框架相关工单实施前必须读取相关 go-admin-doc,并记录文档主题/文件、继承路径、裁剪/隐藏模块和来源证据。
7. 当前旧产品实现不合入新 `main` / `dev`;迁移只从 `explore` 提取已确认业务需求和必要业务适配器。
## 分支与回退证据
- 重建前 main:`be2c1e3c81131b51ee9fba67394ae9928e41104c`
- explore 聚合提交:`9020b32`
- main 规则提交:`0ce30e5`
- main/dev 最终共同基线:`1e724e973189f0fa9bfba8109791658bacd08640`
- Sense 来源:`a9f8a55`
- Bell 来源:`9ee06e3`
- Bell 文档来源:`3c245e1`
- 原型来源:`8d1875c`
所有来源分支均保留。需要回退分支治理时可把 main 恢复到重建前 SHA;需要读取旧产品时使用 `explore` 或对应来源分支。
## 测试
- `python dev_scripts/check_harness.py --strict`:通过。
- `python -m unittest discover -s tests -v`:31 项通过。
- `git diff --check`:通过。
- Gitea Wiki 的 Project-Profile、Development-Workflow、Architecture-and-Code-Map、Local-Development-and-Verification 已先更新并读取确认。
## Gitea 管理配置
通过当前实例 Swagger 描述核对 API 后,使用仓库外安全凭据完成并读取确认:`dev` 已设为默认分支;`main` 与 `dev` 均禁止直接推送和强推;`main` 要求 1 个审批,`dev` 要求 0 个审批以允许 Agent 在测试后合并任务 PR。管理员覆盖保持可用,实际执行仍受“只有用户明确审核后才能进入 main”的仓库规则约束。
## 遗留边界
本任务不导入新的 GoAdmin 产品源码,也不迁移旧业务功能。Sense/Bell 的真正源码派生和逐模块迁移应另建工单并在 `dev` 上实施。
## 修改文件
- 根级与子项目规则:`AGENTS.md`、`Sense/AGENTS.md`、`Bell/AGENTS.md`
- 分支说明:`README.md`
- Wiki 镜像:项目档案、开发工作流、架构与代码地图、本地开发与验证
- 任务归档镜像与映射:`docs/task/58-*.md`、`wiki-docs.json`
- explore 快照说明:`EXPLORE-SNAPSHOT.md`
## 验收结果
| 验收项 | 结果 |
|---|---|
| explore 聚合旧 Sense、Bell、原型和任务证据 | 通过 |
| 原来源分支保持存在 | 通过 |
| main 保持最小治理与重建基线 | 通过 |
| dev 从最终 main 创建 | 通过;两者起点均为 `1e724e9` |
| GoAdmin/go-admin-ui 源码派生与 go-admin-doc 必查规则 | 通过 |
| 用户未跟踪文件未提交 | 通过 |
| Gitea main/dev 保护与默认分支 | 通过;已读取服务器配置确认 |
## 相关提交
- `9020b32`:记录 explore 快照来源
- `0ce30e5`:建立审核分支流和 GoAdmin 源码派生门禁
- `1e724e9`:登记并镜像工单 #58 归档
## 未验证部分
- **未验证部分**:无。产品源码导入和业务迁移明确属于后续工单,不是本任务未验证项。
-4
View File
@@ -83,10 +83,6 @@
{
"page": "Task-5-冻结Sense与Bell-GoAdmin技术基线",
"path": "docs/task/5-冻结Sense与Bell-GoAdmin技术基线.md"
},
{
"page": "Task-58-建立explore-main-dev分支治理并重置GoAdmin开发基线",
"path": "docs/task/58-建立explore-main-dev分支治理并重置GoAdmin开发基线.md"
}
]
}