mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-09 22:31:14 +00:00
265 lines
12 KiB
Markdown
265 lines
12 KiB
Markdown
# Notification System Design
|
||
|
||
## Goal
|
||
|
||
Build an independent in-app notification subsystem for SkillHub that delivers real-time notifications for skill lifecycle events (publish, review, promotion, report), with SSE push, user preference control, and extensibility for future third-party channels.
|
||
|
||
## Scope
|
||
|
||
- **In scope**: In-app notifications, SSE real-time push, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup
|
||
- **Out of scope**: External channels (email, Feishu, DingTalk), migration of existing governance notifications, external webhook delivery
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Domain Services (existing)
|
||
│ ApplicationEventPublisher
|
||
▼
|
||
Domain Events (existing + new)
|
||
│
|
||
├── SearchIndexListener (existing)
|
||
├── StarCounterListener (existing)
|
||
└── NotificationModule (NEW)
|
||
├── NotificationEventListener
|
||
├── RecipientResolver
|
||
├── NotificationPreferenceService (filter)
|
||
├── NotificationDispatcher (channel routing)
|
||
├── NotificationService (persist)
|
||
└── SseEmitterManager (push)
|
||
```
|
||
|
||
The notification module consumes domain events via `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("skillhubEventExecutor")`, following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is lightweight (DB insert + SSE push).
|
||
|
||
## Data Model
|
||
|
||
### notification
|
||
|
||
| Column | Type | Description |
|
||
|--------|------|-------------|
|
||
| id | BIGSERIAL PK | |
|
||
| recipient_id | VARCHAR(128) NOT NULL | Recipient user ID |
|
||
| category | VARCHAR(32) NOT NULL | PUBLISH / REVIEW / PROMOTION / REPORT |
|
||
| event_type | VARCHAR(64) NOT NULL | e.g. skill.published, review.approved |
|
||
| title | VARCHAR(200) NOT NULL | Human-readable title |
|
||
| body_json | TEXT | Structured payload (see body_json schema below) |
|
||
| entity_type | VARCHAR(64) | skill / review / report (for navigation) |
|
||
| entity_id | BIGINT | Associated entity ID |
|
||
| status | VARCHAR(20) NOT NULL DEFAULT 'UNREAD' | UNREAD / READ |
|
||
| created_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
|
||
| read_at | TIMESTAMPTZ | |
|
||
|
||
Indexes:
|
||
- `(recipient_id, created_at DESC)` — notification list
|
||
- `(recipient_id, status, created_at DESC)` — unread filter
|
||
|
||
### notification_preference
|
||
|
||
| Column | Type | Description |
|
||
|--------|------|-------------|
|
||
| id | BIGSERIAL PK | |
|
||
| user_id | VARCHAR(128) NOT NULL | |
|
||
| category | VARCHAR(32) NOT NULL | PUBLISH / REVIEW / PROMOTION / REPORT |
|
||
| channel | VARCHAR(32) NOT NULL | IN_APP (future: EMAIL, FEISHU, DINGTALK) |
|
||
| enabled | BOOLEAN NOT NULL DEFAULT TRUE | |
|
||
| UNIQUE(user_id, category, channel) | | |
|
||
|
||
Behavior: When no explicit preference exists, default to `enabled = true` for all categories on `IN_APP` channel. No pre-inserted rows needed.
|
||
|
||
### body_json Schema
|
||
|
||
The `title` column stores an i18n message key (e.g. `notification.review.approved`). The `body_json` column stores interpolation parameters as JSON. The frontend renders the title via `t(title, JSON.parse(bodyJson))`.
|
||
|
||
Common envelope:
|
||
```json
|
||
{
|
||
"skillName": "generate-commit",
|
||
"skillSlug": "generate-commit",
|
||
"namespace": "team-ai",
|
||
"version": "1.0.0",
|
||
"actor": "admin"
|
||
}
|
||
```
|
||
|
||
Additional fields per event type:
|
||
- `review.*`: `reviewId`, `reason` (for rejected)
|
||
- `promotion.*`: `promotionId`, `reason` (for rejected)
|
||
- `report.*`: `reportId`, `action` (for resolved: "resolved" / "dismissed" / "hidden" / "archived")
|
||
|
||
## Domain Events
|
||
|
||
### Existing (reuse)
|
||
|
||
- `SkillPublishedEvent(skillId, versionId, publisherId)`
|
||
|
||
### New Events
|
||
|
||
```java
|
||
// Review flow
|
||
ReviewSubmittedEvent(reviewId, skillId, versionId, submitterId, namespaceId)
|
||
ReviewApprovedEvent(reviewId, skillId, versionId, reviewerId, submitterId)
|
||
ReviewRejectedEvent(reviewId, skillId, versionId, reviewerId, submitterId, reason)
|
||
|
||
// Promotion flow
|
||
PromotionSubmittedEvent(promotionId, skillId, versionId, submitterId)
|
||
PromotionApprovedEvent(promotionId, skillId, reviewerId, submitterId)
|
||
PromotionRejectedEvent(promotionId, skillId, reviewerId, submitterId, reason)
|
||
|
||
// Report flow
|
||
ReportSubmittedEvent(reportId, skillId, reporterId)
|
||
ReportResolvedEvent(reportId, skillId, handlerId, reporterId, action)
|
||
```
|
||
|
||
### Domain Service Modifications
|
||
|
||
The following existing services need `ApplicationEventPublisher` injected and `publishEvent()` calls added:
|
||
|
||
| Service | Method | Event to Publish |
|
||
|---------|--------|-----------------|
|
||
| `ReviewService.submitReview()` | After review record created | `ReviewSubmittedEvent` |
|
||
| `ReviewService.approveReview()` | After status set to APPROVED | `ReviewApprovedEvent` |
|
||
| `ReviewService.rejectReview()` | After status set to REJECTED | `ReviewRejectedEvent` |
|
||
| `PromotionService.submitPromotion()` | After promotion request created | `PromotionSubmittedEvent` |
|
||
| `PromotionService.approvePromotion()` | After promotion approved | `PromotionApprovedEvent` |
|
||
| `PromotionService.rejectPromotion()` | After promotion rejected | `PromotionRejectedEvent` |
|
||
| `SkillReportService.submitReport()` | After report created | `ReportSubmittedEvent` |
|
||
| `SkillReportService.resolveReport()` / `dismissReport()` | After report resolved | `ReportResolvedEvent` |
|
||
|
||
`SkillPublishService` already publishes `SkillPublishedEvent` — no change needed.
|
||
`SkillReportService` does not currently inject `ApplicationEventPublisher` — it needs to be added.
|
||
|
||
### New Repository Methods for Recipient Resolution
|
||
|
||
| Repository | New Method | Purpose |
|
||
|-----------|-----------|---------|
|
||
| `NamespaceMemberRepository` | `findByNamespaceIdAndRoleIn(Long nsId, Collection<NamespaceRole> roles)` | Find namespace ADMIN/OWNER for review.submitted |
|
||
| `UserRoleBindingRepository` | `findByRoleCode(String roleCode)` | Find platform SKILL_ADMIN users for promotion/report events |
|
||
|
||
### Event → Notification Mapping
|
||
|
||
| category | event_type | Trigger | Recipients |
|
||
|----------|-----------|---------|------------|
|
||
| PUBLISH | skill.published | Skill auto-published | Skill author |
|
||
| REVIEW | review.submitted | New version submitted for review | Namespace ADMIN/OWNER |
|
||
| REVIEW | review.approved | Review approved | Skill author (submitter) |
|
||
| REVIEW | review.rejected | Review rejected | Skill author (submitter) |
|
||
| PROMOTION | promotion.submitted | Promotion request submitted | Platform SKILL_ADMIN |
|
||
| PROMOTION | promotion.approved | Promotion approved | Requester |
|
||
| PROMOTION | promotion.rejected | Promotion rejected | Requester |
|
||
| REPORT | report.submitted | New report filed | Platform SKILL_ADMIN |
|
||
| REPORT | report.resolved | Report resolved | Reporter |
|
||
|
||
## Module Structure
|
||
|
||
New Maven module: `skillhub-notification`
|
||
|
||
Dependencies: `skillhub-notification` → `skillhub-domain` (domain events, entities, repositories). Both `NotificationEventListener` and `RecipientResolver` live in `skillhub-app` (following the existing pattern of `SkillStarEventListener` and `SkillRatingEventListener`), where they can access `skillhub-notification` services, `skillhub-domain` repositories, and `skillhub-auth` for role resolution. This avoids a cross-module dependency from `skillhub-notification` to `skillhub-auth`.
|
||
|
||
```
|
||
skillhub-notification/ -- new module (depends on: skillhub-domain)
|
||
├── domain/
|
||
│ ├── Notification.java
|
||
│ ├── NotificationCategory.java -- enum: PUBLISH, REVIEW, PROMOTION, REPORT
|
||
│ ├── NotificationChannel.java -- enum: IN_APP (future: EMAIL, FEISHU...)
|
||
│ ├── NotificationPreference.java
|
||
│ ├── NotificationRepository.java
|
||
│ └── NotificationPreferenceRepository.java
|
||
├── service/
|
||
│ ├── NotificationService.java -- CRUD: create, list, mark read, batch read, unread count
|
||
│ ├── NotificationPreferenceService.java -- preference CRUD + default fallback
|
||
│ └── NotificationDispatcher.java -- route by channel (currently IN_APP only)
|
||
└── sse/
|
||
└── SseEmitterManager.java -- manage SSE connections: register, push, heartbeat, cleanup
|
||
|
||
skillhub-app/ -- existing module
|
||
└── listener/
|
||
├── NotificationEventListener.java -- consume domain events, call RecipientResolver + Dispatcher
|
||
└── RecipientResolver.java -- resolve recipient list per event type (needs auth + domain repos)
|
||
```
|
||
|
||
## SSE Real-Time Push
|
||
|
||
- Endpoint: `GET /api/notifications/sse`
|
||
- `SseEmitterManager` uses `ConcurrentHashMap<String, CopyOnWriteArrayList<SseEmitter>>` (thread-safe for concurrent tab open/close)
|
||
- Per-user connection limit: max 5 emitters (reject new connections beyond limit)
|
||
- Global connection limit: max 1000 emitters (configurable, reject with 503 when exceeded)
|
||
- SseEmitter timeout: 60s, browser `EventSource` auto-reconnects
|
||
- Heartbeat: `:ping` every 30s to prevent proxy/LB disconnection
|
||
- On emitter complete/timeout/error: auto-remove from map
|
||
- Push failure: silent ignore (notification already persisted, visible on refresh)
|
||
- On `EventSource` reconnect: frontend fetches unread count to sync badge
|
||
|
||
## API Design
|
||
|
||
```
|
||
GET /api/notifications -- List (paginated + category filter)
|
||
GET /api/notifications/unread-count -- Unread count (for bell badge)
|
||
PUT /api/notifications/{id}/read -- Mark single as read
|
||
PUT /api/notifications/read-all -- Mark all as read
|
||
GET /api/notifications/sse -- SSE connection
|
||
|
||
GET /api/notification-preferences -- Get current user preferences
|
||
PUT /api/notification-preferences -- Batch update preferences
|
||
```
|
||
|
||
Response format follows existing SkillHub API conventions (code + data wrapper).
|
||
|
||
## Frontend
|
||
|
||
### Bell Component (global nav bar)
|
||
- Bell icon in nav bar, left of user avatar
|
||
- Red badge with unread count (> 99 shows "99+")
|
||
- Click to expand dropdown
|
||
|
||
### Dropdown List
|
||
- Shows latest 5 notifications
|
||
- Each item: title + relative time ("3 minutes ago")
|
||
- Click item → navigate to entity page + mark as read
|
||
- Footer: "View all notifications" link
|
||
- Header: "Mark all as read" button
|
||
|
||
### Notification Page (`/dashboard/notifications`)
|
||
- Full notification list with pagination
|
||
- Tab filter by category: All / Publish / Review / Promotion / Report
|
||
- Batch mark all as read
|
||
- Click to navigate
|
||
|
||
### Preference Settings (`/settings/notifications`)
|
||
- Grouped by category, each with toggle switch
|
||
- Currently shows IN_APP channel column only
|
||
- Future: expand to category × channel matrix when new channels are added
|
||
|
||
## Data Cleanup
|
||
|
||
- Scheduled task (`@Scheduled`) runs daily at 2:00 AM
|
||
- Read notifications: retain 30 days
|
||
- Unread notifications: retain 90 days
|
||
- Retention periods configurable
|
||
- Use `ShedLock` or database advisory lock to ensure single-instance execution in multi-pod deployments
|
||
- If using ShedLock: add `shedlock-spring` + `shedlock-provider-jdbc-template` as new Maven dependencies
|
||
|
||
## Configuration
|
||
|
||
```yaml
|
||
skillhub:
|
||
notification:
|
||
sse-timeout: 60s
|
||
sse-heartbeat: 30s
|
||
cleanup:
|
||
read-retention-days: 30
|
||
unread-retention-days: 90
|
||
```
|
||
|
||
## Relationship with Existing Governance Notifications
|
||
|
||
- Existing `GovernanceNotificationService` + `user_notification` table remain untouched
|
||
- New notification system runs independently in parallel
|
||
- Existing governance notification UI (inside governance center) unchanged
|
||
- Bell component reads only from new `notification` table
|
||
- Future unification (migrating old data to new table) is out of scope for this phase
|
||
|
||
## Extensibility
|
||
|
||
- New event types: add domain event record + mapping in `NotificationEventListener`
|
||
- New channels: add enum value to `NotificationChannel` + implement channel-specific dispatcher
|
||
- Third-party integrations: add new `@TransactionalEventListener` beans that consume the same domain events
|
||
- Preference table already supports category × channel granularity, no schema change needed
|