Ramazanmvr/campus-incident-response

GitHub: Ramazanmvr/campus-incident-response

一个基于Go的校园事件响应学习项目,实现事件生命周期管理和Kafka集成,用于后端开发实践。

Stars: 0 | Forks: 0

# 校园事件响应 一个用于独立进行 Go 后端开发的学习型宠物项目。 前端已经编写完成。你的任务是:完整实现后端,连接 PostgreSQL 和 Kafka/Redpanda,然后将前端从 demo 模式切换到实际 API。 ## 项目理念 你正在为一个校园事件响应系统编写后端: - 学生、教职员工和内部服务可以创建事件; - 操作员根据严重程度和类别对事件进行分类; - 系统分配响应团队; - 事件遵循严格的生命周期; - SLA 自动计算; - 外部告警通过 Kafka 接收; - 所有领域事件通过 outbox 发布到 Kafka; - 前端展示事件、团队、校园资产、告警和事件日志。 这不是一个电商平台,也不是为了 CRUD 而 CRUD。这里包含有价值的后端实践:状态机、事务、幂等性、SLA、告警异步处理、outbox 模式、读模型、并发团队分配。 ## 已完成内容 ``` frontend/ index.html styles.css app.js config.js backend/ README.md ``` 后端代码被故意省略。 ## 如何打开前端 ``` open frontend/index.html ``` 默认启用了 demo 模式。它的目的是让你查看界面和业务场景。 当后端准备好后,修改 `frontend/config.js`: ``` demoMode: false, rpcBaseUrl: "http://localhost:8080" ``` 之后,前端将开始通过 Connect JSON 端点调用你的 gRPC API。 重要提示:浏览器前端不能像后端服务那样,直接像调用普通原生 gRPC over HTTP/2 那样简单地调用。因此,在这个项目中,后端应该是 gRPC-first,而对于浏览器,则需要搭建一个兼容的 Connect/gRPC-Web 层。在 Go 中,通过 `connectrpc.com/connect` 可以方便地做到这一点:你描述 `.proto`,生成 Go 代码,然后通过 HTTP 提供 RPC 方法。 ## 推荐技术栈 对于 Go 实践,我推荐: - Go 1.22+ - PostgreSQL - Kafka 或 Redpanda - Docker Compose - `buf` - `google.golang.org/grpc` - `connectrpc.com/connect` - `connectrpc.com/grpcreflect` - `net/http` - `pgxpool` - `sqlc` 或基于 `pgx` 的细致 repository 层 - `golang-migrate` 或 `goose` - `segmentio/kafka-go` - `slog` - `testify` ## 主要实体 最少需要以下表: - `incidents` - `incident_updates` - `teams` - `team_members` - `campus_assets` - `alerts` - `incident_assignments` - `outbox_events` - `idempotency_keys` - `processed_kafka_messages` ## 事件生命周期 ``` NEW -> TRIAGED -> ASSIGNED -> IN_PROGRESS -> RESOLVED -> CLOSED \ \ \ \ ------- CANCELLED -------------- ``` 此外,事件还有严重程度: ``` SEV1 - критичный инцидент, например полный отказ сети в корпусе SEV2 - серьезный, влияет на группу пользователей SEV3 - обычный, влияет на одного пользователя или аудиторию SEV4 - низкий приоритет ``` SLA 取决于严重程度: ``` SEV1: first response <= 10 минут, resolution <= 2 часа SEV2: first response <= 30 минут, resolution <= 8 часов SEV3: first response <= 4 часа, resolution <= 2 дня SEV4: first response <= 1 день, resolution <= 5 дней ``` ## 核心后端场景 ### 1. 创建事件 前端发送问题描述。 后端必须: - 验证类别、资产和报告者; - 以 `NEW` 状态创建事件; - 计算 SLA 截止时间; - 将 `incident.created` 写入 outbox; - 返回已创建的事件。 ### 2. 分诊 操作员点击“分诊”按钮。 后端必须: - 仅允许对 `NEW` 状态的事件进行此操作; - 设置严重程度、类别、摘要; - 将事件状态转为 `TRIAGED`; - 如果尚未记录,则记录首次响应时间; - 写入事件 `incident.triaged`。 ### 3. 分配团队 操作员点击“分配”按钮。 后端必须: - 仅允许对 `TRIAGED` 或 `ASSIGNED` 状态的事件进行分配; - 检查团队是否处于活动状态; - 检查团队当前活跃的 SEV1/SEV2 任务数量限制; - 分配团队; - 将状态转为 `ASSIGNED`; - 写入 `team.assigned` 和 `incident.assigned`。 重要提示:两个并行的分配不应在没有控制的情况下相互覆盖。使用事务和行锁来锁定事件记录。 ### 4. 开始工作 团队点击“开始工作”按钮。 后端: - 仅允许对 `ASSIGNED` 状态的事件进行此操作; - 将事件状态转为 `IN_PROGRESS`; - 写入 `incident.work_started`。 ### 5. 解决 团队点击“解决”按钮。 后端: - 仅允许对 `IN_PROGRESS` 状态的事件进行此操作; - 要求提供解决方案说明; - 将事件状态转为 `RESOLVED`; - 保存解决时间; - 写入 `incident.resolved`。 ### 6. 关闭 操作员点击“关闭”按钮。 后端: - 仅允许对 `RESOLVED` 状态的事件进行此操作; - 将事件状态转为 `CLOSED`; - 写入 `incident.closed`。 ### 7. 升级 操作员点击“升级”按钮。 后端: - 允许对 `NEW`、`TRIAGED`、`ASSIGNED`、`IN_PROGRESS` 状态的事件进行升级; - 如果可能,将严重程度提高一个等级; - 重新计算 SLA; - 写入 `incident.escalated`。 ## gRPC API 后端应为 gRPC-first。主要契约在 `.proto` 文件中描述,例如: ``` proto/incident/v1/incident.proto ``` 为浏览器前端在 `frontend/config.js` 中指定的同一地址上搭建 Connect 端点。 前端调用方法的方式如下: ``` POST /incident.v1.IncidentService/ListIncidents POST /incident.v1.IncidentService/CreateIncident POST /incident.v1.IncidentService/RunIncidentCommand POST /incident.v1.IncidentService/ListTeams POST /incident.v1.IncidentService/ListAssets POST /incident.v1.IncidentService/ListAlerts POST /incident.v1.IncidentService/ListEvents ``` 请求头: ``` Content-Type: application/json Connect-Protocol-Version: 1 Idempotency-Key: # для create/command методов ``` 这不是一个资源导向的 HTTP API:所有操作都作为服务的 RPC 方法调用。同时,Connect 允许静态前端从浏览器调用 gRPC 契约,无需单独的网关。 ### 服务 ``` syntax = "proto3"; package incident.v1; import "google/protobuf/struct.proto"; option go_package = "github.com/yourname/campus-incident-response/gen/incident/v1;incidentv1"; service IncidentService { rpc ListIncidents(ListIncidentsRequest) returns (ListIncidentsResponse); rpc CreateIncident(CreateIncidentRequest) returns (CreateIncidentResponse); rpc RunIncidentCommand(RunIncidentCommandRequest) returns (RunIncidentCommandResponse); rpc ListTeams(ListTeamsRequest) returns (ListTeamsResponse); rpc ListAssets(ListAssetsRequest) returns (ListAssetsResponse); rpc ListAlerts(ListAlertsRequest) returns (ListAlertsResponse); rpc LinkAlert(LinkAlertRequest) returns (LinkAlertResponse); rpc ListEvents(ListEventsRequest) returns (ListEventsResponse); } ``` ### 消息 最小契约: ``` message Incident { string id = 1; string title = 2; string description = 3; IncidentStatus status = 4; Severity severity = 5; string category = 6; string asset_id = 7; string asset_name = 8; string reporter_name = 9; string reporter_email = 10; string assigned_team_id = 11; string assigned_team_name = 12; string created_at = 13; string first_response_due_at = 14; string resolution_due_at = 15; optional string resolved_at = 16; repeated IncidentUpdate updates = 17; } message IncidentUpdate { string at = 1; string author = 2; string message = 3; } message ListIncidentsRequest {} message ListIncidentsResponse { repeated Incident incidents = 1; } message CreateIncidentRequest { string title = 1; string description = 2; string category = 3; Severity severity = 4; string asset_id = 5; string reporter_name = 6; string reporter_email = 7; } message CreateIncidentResponse { Incident incident = 1; } message RunIncidentCommandRequest { string incident_id = 1; IncidentCommand command = 2; optional Severity severity = 3; optional string category = 4; optional string team_id = 5; optional string note = 6; } message RunIncidentCommandResponse { Incident incident = 1; } message Team { string id = 1; string name = 2; string specialization = 3; int32 active_incidents = 4; bool on_call = 5; int32 capacity = 6; } message ListTeamsRequest {} message ListTeamsResponse { repeated Team teams = 1; } message CampusAsset { string id = 1; string name = 2; string type = 3; string location = 4; string criticality = 5; string status = 6; } message ListAssetsRequest {} message ListAssetsResponse { repeated CampusAsset assets = 1; } message Alert { string id = 1; string source = 2; string type = 3; Severity severity = 4; string asset_id = 5; string asset_name = 6; string status = 7; string message = 8; string created_at = 9; string linked_incident_id = 10; } message ListAlertsRequest {} message ListAlertsResponse { repeated Alert alerts = 1; } message LinkAlertRequest { string alert_id = 1; string incident_id = 2; } message LinkAlertResponse { Alert alert = 1; } message DomainEvent { string id = 1; string type = 2; string aggregate_id = 3; string aggregate_type = 4; google.protobuf.Struct payload = 5; string created_at = 6; bool published = 7; } message ListEventsRequest {} message ListEventsResponse { repeated DomainEvent events = 1; } enum IncidentStatus { STATUS_UNSPECIFIED = 0; NEW = 1; TRIAGED = 2; ASSIGNED = 3; IN_PROGRESS = 4; RESOLVED = 5; CLOSED = 6; CANCELLED = 7; } enum Severity { SEVERITY_UNSPECIFIED = 0; SEV1 = 1; SEV2 = 2; SEV3 = 3; SEV4 = 4; } enum IncidentCommand { COMMAND_UNSPECIFIED = 0; TRIAGE = 1; ASSIGN = 2; START_WORK = 3; RESOLVE = 4; CLOSE = 5; ESCALATE = 6; CANCEL = 7; } ``` 为了让前端无需额外构建即可工作,上面 `.proto` 中的枚举值名称与界面发送的名称一致: ``` { "status": "NEW", "severity": "SEV2", "command": "TRIAGE" } ``` 如果你决定使用更严格的 protobuf 命名风格(例如 `INCIDENT_STATUS_NEW`、`SEVERITY_SEV2`、`INCIDENT_COMMAND_TRIAGE`),则需要在 RPC handler 边界添加映射,或者根据这些值更新前端。 ### `ListIncidents` Request: ``` {} ``` Response: ``` { "incidents": [ { "id": "inc-1001", "title": "Отказ Wi-Fi на 3 этаже", "description": "Студенты не могут подключиться к eduroam.", "status": "NEW", "severity": "SEV2", "category": "network", "assetId": "asset-1", "assetName": "Сеть кампуса на Кронверкском", "reporterName": "Анна Петрова", "reporterEmail": "anna@example.com", "assignedTeamId": "", "assignedTeamName": "", "createdAt": "2026-05-19T10:00:00Z", "firstResponseDueAt": "2026-05-19T10:30:00Z", "resolutionDueAt": "2026-05-19T18:00:00Z", "resolvedAt": null, "updates": [ { "at": "2026-05-19T10:00:00Z", "author": "system", "message": "Инцидент создан" } ] } ] } ``` ### `CreateIncident` Request: ``` { "title": "Не работает проектор", "description": "Аудитория 3408, нет HDMI-сигнала", "category": "classroom", "severity": "SEV3", "assetId": "asset-3", "reporterName": "Даниил Орлов", "reporterEmail": "daniil@example.com" } ``` Response: ``` { "incident": { "id": "inc-1007", "title": "Не работает проектор", "status": "NEW", "severity": "SEV3" } } ``` ### `RunIncidentCommand` Request: ``` { "incidentId": "inc-1001", "command": "TRIAGE", "severity": "SEV2", "category": "network", "teamId": "team-net", "note": "Подтверждено влияние на 3 этаж" } ``` Response: ``` { "incident": { "id": "inc-1001", "status": "TRIAGED", "severity": "SEV2" } } ``` ### `ListTeams` Response: ``` { "teams": [ { "id": "team-net", "name": "Сетевые операции", "specialization": "network", "activeIncidents": 3, "onCall": true, "capacity": 6 } ] } ``` ### `ListAssets` Response: ``` { "assets": [ { "id": "asset-1", "name": "Сеть кампуса на Кронверкском", "type": "network", "location": "Кронверкский пр., 49", "criticality": "high", "status": "DEGRADED" } ] } ``` ### `ListAlerts` Response: ``` { "alerts": [ { "id": "alt-1", "source": "wifi-controller", "type": "packet_loss", "severity": "SEV2", "assetId": "asset-1", "assetName": "Сеть кампуса на Кронверкском", "status": "OPEN", "message": "Потери пакетов выше порога", "createdAt": "2026-05-19T09:55:00Z", "linkedIncidentId": "inc-1001" } ] } ``` ### `LinkAlert` Request: ``` { "alertId": "alt-1", "incidentId": "inc-1001" } ``` ### `ListEvents` Response: ``` { "events": [ { "id": "evt-1", "type": "incident.created", "aggregateId": "inc-1001", "aggregateType": "incident", "payload": { "incidentId": "inc-1001", "severity": "SEV2" }, "createdAt": "2026-05-19T10:00:00Z", "published": true } ] } ``` ### gRPC 错误 错误应作为 gRPC/Connect 错误码返回: - `invalid_argument` — 无效的请求; - `not_found` — 实体未找到; - `failed_precondition` — 状态机转换无效; - `already_exists` — 没有幂等 key 时的重复操作; - `internal` — 意外错误。 ## Kafka ### Topic `incident.events` 后端通过 outbox 发布所有领域事件。 示例: ``` { "eventId": "evt-01J...", "type": "incident.assigned", "aggregateId": "inc-1001", "aggregateType": "incident", "occurredAt": "2026-05-19T10:10:00Z", "payload": { "incidentId": "inc-1001", "teamId": "team-net", "severity": "SEV2" } } ``` ### Topic `external.alerts` Consumer 读取来自外部系统的告警。 示例: ``` { "messageId": "msg-123", "source": "wifi-controller", "type": "packet_loss", "severity": "SEV2", "assetId": "asset-1", "message": "Packet loss above threshold", "detectedAt": "2026-05-19T09:55:00Z" } ``` Consumer 必须: - 基于 `messageId` 实现幂等; - 保存告警; - 如果是 `SEV1` 或 `SEV2`,则自动创建事件或关联到类似的打开事件; - 写入事件 `alert.received`、`incident.created_from_alert` 或 `alert.linked`。 ### Topic `team.dispatch.commands` 这是增加复杂性的额外一层。 在分配团队时,后端可以发布命令: ``` { "commandId": "cmd-123", "type": "notify_team", "teamId": "team-net", "incidentId": "inc-1001", "severity": "SEV2" } ``` ## Outbox 模式 项目的必要部分: 1. 在业务事务中更改事件状态。 2. 在同一事务中将事件插入 `outbox_events`。 3. 一个单独的 worker 将事件发布到 Kafka。 4. 发布成功后,将事件标记为 `published`。 ## 强制要求 后端必须: - 通过 Docker Compose 启动; - 包含 PostgreSQL 和 Kafka/Redpanda; - 包含数据库迁移; - 提供本 README 中定义的 gRPC API 和用于前端的 Connect 端点; - 实现事件状态机; - 计算 SLA 截止时间; - 不允许无效的状态转换; - 为 `CreateIncident` 和 `RunIncidentCommand` 支持幂等 key; - 通过 outbox 发布事件; - 为 `external.alerts` 拥有 consumer; - 使用事务保护并发的团队分配; - 拥有针对关键场景的集成测试。 ## 必须通过测试验证的内容 - 创建事件会计算 SLA; - 不能在 `ASSIGNED` 状态后调用 `TRIAGE`; - 不能在 `TRIAGED` 状态前调用 `ASSIGN`; - 没有团队时不能调用 `START_WORK`; - `RESOLVE` 需要提供说明; - 不能在 `RESOLVED` 状态前调用 `CLOSE`; - `ESCALATE` 会更改严重程度和 SLA; - 两个并行的 `ASSIGN` 不会导致冲突状态; - 使用相同 `Idempotency-Key` 的重复命令不会第二次执行命令; - consumer 不会处理同一个 Kafka 消息两次; - outbox worker 在重启后不会丢失事件。 ## 附加功能 当基础版本完成后: - WebSocket 或 SSE 用于实时事件流; - 通过 JWT 进行操作员授权; - 角色:`admin`、`operator`、`responder`、`viewer`; - 过滤和分页; - 通过 Buf 或 grpcurl 示例生成 protobuf 文档; - Prometheus 指标; - 用于无效 Kafka 消息的 DLQ(死信队列); - 审计日志; - 事件的全文搜索; - 类似告警的自动去重。 ## 完成标准 项目完成当: - 前端在 `demoMode: false` 模式下正常工作; - 前端的所有按钮都通过 Connect 端点调用真实的 gRPC 后端; - 数据能在后端重启后保留; - Kafka 能接收到事件; - consumer 能接收外部告警; - outbox 事件变为 `published` 状态; - 存在迁移脚本和初始数据; - 存在针对状态机、幂等性和 Kafka consumer 的测试。
标签:API开发, Go语言, Kafka, outbox模式, pet项目, PostgreSQL, Python工具, Redpanda, SLA监控, SonarQube插件, 事件管理, 事务处理, 前端集成, 后端开发, 幂等性, 并发分配, 异步处理, 教育项目, 日志审计, 校园事件响应, 测试用例, 版权保护, 状态机, 程序破解, 读模型