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插件, 事件管理, 事务处理, 前端集成, 后端开发, 幂等性, 并发分配, 异步处理, 教育项目, 日志审计, 校园事件响应, 测试用例, 版权保护, 状态机, 程序破解, 读模型