feat: add development-only synthetic Bell events (#18)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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":{}}}
|
||||
@@ -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"}}}
|
||||
@@ -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}}}
|
||||
@@ -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,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
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user