diff --git a/Bell/README.md b/Bell/README.md
index 072ad58..f4317e0 100644
--- a/Bell/README.md
+++ b/Bell/README.md
@@ -60,6 +60,12 @@ 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.
+
## 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`.
diff --git a/Bell/server/app/rbac/permissions.go b/Bell/server/app/rbac/permissions.go
index 22ca365..42bf61f 100644
--- a/Bell/server/app/rbac/permissions.go
+++ b/Bell/server/app/rbac/permissions.go
@@ -6,15 +6,16 @@ import (
)
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"
+ 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{}
diff --git a/Bell/server/app/synthetic/http.go b/Bell/server/app/synthetic/http.go
new file mode 100644
index 0000000..9cd678c
--- /dev/null
+++ b/Bell/server/app/synthetic/http.go
@@ -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)
+}
diff --git a/Bell/server/app/synthetic/service.go b/Bell/server/app/synthetic/service.go
new file mode 100644
index 0000000..d65c0f1
--- /dev/null
+++ b/Bell/server/app/synthetic/service.go
@@ -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)
+}
diff --git a/Bell/server/app/synthetic/service_test.go b/Bell/server/app/synthetic/service_test.go
new file mode 100644
index 0000000..730205e
--- /dev/null
+++ b/Bell/server/app/synthetic/service_test.go
@@ -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)
+ }
+}
diff --git a/Bell/server/cmd/bell/root.go b/Bell/server/cmd/bell/root.go
index 7233569..aa51d04 100644
--- a/Bell/server/cmd/bell/root.go
+++ b/Bell/server/cmd/bell/root.go
@@ -15,6 +15,7 @@ import (
"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/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"
@@ -67,6 +68,9 @@ func Run(ctx context.Context, args []string) error {
authHTTP := auth.HTTP{Service: authService, Store: authStore, Audit: auditStore, SecureCookie: cfg.CookieSecure}
authHTTP.Register(app.Router())
event.HTTP{Service: event.Service{DB: db}, Auth: authHTTP}.Register(app.Router())
+ if cfg.Environment == "development" || cfg.Environment == "test" {
+ synthetic.HTTP{Service: synthetic.Service{Events: event.Service{DB: db}}, 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()
diff --git a/Bell/server/migrations/2026081203_synthetic_permission.sql b/Bell/server/migrations/2026081203_synthetic_permission.sql
new file mode 100644
index 0000000..1b706ee
--- /dev/null
+++ b/Bell/server/migrations/2026081203_synthetic_permission.sql
@@ -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';
diff --git a/Bell/server/testdata/events/danger-zone.json b/Bell/server/testdata/events/danger-zone.json
new file mode 100644
index 0000000..123b172
--- /dev/null
+++ b/Bell/server/testdata/events/danger-zone.json
@@ -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"}}}
diff --git a/Bell/server/testdata/events/fixtures.go b/Bell/server/testdata/events/fixtures.go
new file mode 100644
index 0000000..1da095c
--- /dev/null
+++ b/Bell/server/testdata/events/fixtures.go
@@ -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)
+}
diff --git a/Bell/server/testdata/events/invalid-fields.json b/Bell/server/testdata/events/invalid-fields.json
new file mode 100644
index 0000000..0a86a97
--- /dev/null
+++ b/Bell/server/testdata/events/invalid-fields.json
@@ -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":{}}}
diff --git a/Bell/server/testdata/events/line-crossing.json b/Bell/server/testdata/events/line-crossing.json
new file mode 100644
index 0000000..a398ec4
--- /dev/null
+++ b/Bell/server/testdata/events/line-crossing.json
@@ -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"}}}
diff --git a/Bell/server/testdata/events/missing-evidence.json b/Bell/server/testdata/events/missing-evidence.json
new file mode 100644
index 0000000..eb7322b
--- /dev/null
+++ b/Bell/server/testdata/events/missing-evidence.json
@@ -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}}}
diff --git a/Bell/web/src/api/synthetic/index.js b/Bell/web/src/api/synthetic/index.js
new file mode 100644
index 0000000..8722ac5
--- /dev/null
+++ b/Bell/web/src/api/synthetic/index.js
@@ -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)
diff --git a/Bell/web/src/bootstrap/router.js b/Bell/web/src/bootstrap/router.js
index 0deb729..ed7cde0 100644
--- a/Bell/web/src/bootstrap/router.js
+++ b/Bell/web/src/bootstrap/router.js
@@ -4,6 +4,8 @@ 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 { listFixtures } from '../api/synthetic'
import store from './store'
const router = createRouter({
@@ -12,6 +14,7 @@ const router = createRouter({
{ 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: '/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' } }
]
@@ -23,6 +26,7 @@ router.beforeEach(async to => {
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
})
diff --git a/Bell/web/src/layout/AppLayout.vue b/Bell/web/src/layout/AppLayout.vue
index 18498db..9f74186 100644
--- a/Bell/web/src/layout/AppLayout.vue
+++ b/Bell/web/src/layout/AppLayout.vue
@@ -7,6 +7,7 @@
事件查询
用户与角色
认证审计
+ 合成事件测试
@@ -17,8 +18,9 @@
diff --git a/Bell/web/src/views/synthetic-event/SyntheticEvent.vue b/Bell/web/src/views/synthetic-event/SyntheticEvent.vue
new file mode 100644
index 0000000..b37493a
--- /dev/null
+++ b/Bell/web/src/views/synthetic-event/SyntheticEvent.vue
@@ -0,0 +1,4 @@
+{{item.label}}预期失败保持编号不变可验证重复;换用另一夹具可验证冲突。注入合成事件接收结果{{result.duplicate?'重复请求,返回原收据':'首次接收'}}{{result.event.id}}{{result.receipt.id}}
+