feat: add development-only synthetic Bell events (#18)

This commit is contained in:
QiuSW
2026-08-12 17:53:24 +08:00
parent 64a1d82db4
commit 55d54d06db
16 changed files with 249 additions and 11 deletions
+6
View File
@@ -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`.
+10 -9
View File
@@ -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{}
+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)
}
}
+4
View File
@@ -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()
@@ -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';
+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}}}
+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)
+4
View File
@@ -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
})
+4 -2
View File
@@ -7,6 +7,7 @@
<router-link v-if="has('events:read')" to="/events" 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">
@@ -17,8 +18,9 @@
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed,onMounted,ref } from 'vue'
import { useRoute,useRouter } from 'vue-router'
import { useStore } from 'vuex'
const store=useStore();const route=useRoute();const router=useRouter();const user=computed(()=>store.state.identity.user);const has=permission=>store.getters['identity/has'](permission);async function signOut(){await store.dispatch('identity/logout');await router.replace('/login')}
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>
@@ -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>