From 5ff8bda99e4359b191f9674d54aa5631cd43c7cb Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 22:33:40 -0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=AB=99=E5=86=85=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=20(Phase=201-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Phase 1 — 精准推送基础 - 新增 notification-service 微服务 (port 3013) - DB迁移 007: notifications, notification_reads, notification_tenant_targets 表 - DB迁移 008: tenant_tags, tenant_tag_assignments, notification_user_targets 表 + notifications 表新增 target_tag_ids/target_tag_logic/target_plans/target_statuses/channel_key 字段 - auth-service: TenantTagController — 租户标签 CRUD + 批量分配 (9个接口) - notification-service 支持 7 种推送目标类型: ALL / SPECIFIC_TENANTS / SPECIFIC_USERS / BY_TENANT_TAG(ANY|ALL) / BY_PLAN / BY_TENANT_STATUS / BY_SEGMENT - Web Admin: /tenant-tags 标签管理页 + 通知表单全面扩展 ## Phase 2 — 通知频道与用户偏好 - DB迁移 009: notification_channels (6个预置频道) + user_notification_preferences + notification_segment_members 表 (Phase 4 人群包) - notification-service: ChannelRepository + NotificationChannelController (频道 CRUD + 用户偏好 API,强制频道不可关闭) - Web Admin: /notification-channels 频道管理页 - Flutter: NotificationPreferencesPage — 用户按频道 toggle 订阅,profile页新增入口 ## Phase 3 — Campaign 活动与数据分析 - DB迁移 010: notification_campaigns, campaign_execution_log, notification_event_log 表 - notification-service: CampaignRepository + CampaignAdminController (ONCE/RECURRING调度, 排期/取消/删除, 发送量/阅读率统计) - Web Admin: /campaigns 推送活动管理页 (状态机 + 数据统计弹窗) ## Phase 4 — 事件触发与人群包 - EventTriggerService: Redis Stream 消费者,监听并自动创建通知: billing.payment_failed / billing.quota_warning / tenant.registered / alert.fired - SegmentRepository + SegmentAdminController (全量同步/增量添加/删除) - Web Admin: /segments 人群包管理页 (成员管理 + ETL全量替换) ## 基础设施 - Kong: 新增 notification-service 服务 + 6条路由 + JWT插件 - Docker Compose: 新增 notification-service 容器 (13013:3013) - notification-service 新增 ioredis 依赖 (Redis Stream 消费) ## Flutter (APK需手动编译) - 新增路由: /notifications/inbox, /notifications/preferences - 新增: NotificationInboxPage, NotificationPreferencesPage - 新增: ForceReadNotificationDialog (强制阅读拦截弹窗) - profile页: 站内消息行(未读角标) + 通知偏好设置入口 Co-Authored-By: Claude Sonnet 4.6 --- deploy/docker/docker-compose.yml | 33 + .../src/app/(admin)/campaigns/page.tsx | 488 ++++++++++++ .../(admin)/notification-channels/page.tsx | 297 ++++++++ .../src/app/(admin)/notifications/page.tsx | 705 ++++++++++++++++++ .../src/app/(admin)/segments/page.tsx | 320 ++++++++ .../src/app/(admin)/tenant-tags/page.tsx | 279 +++++++ .../src/domain/entities/notification.ts | 102 +++ .../src/domain/entities/tenant-tag.ts | 29 + .../src/i18n/locales/en/sidebar.json | 5 + .../src/i18n/locales/zh/sidebar.json | 5 + .../api-notification.repository.ts | 76 ++ .../repositories/api-tenant-tag.repository.ts | 76 ++ .../components/layout/sidebar.tsx | 10 + it0_app/lib/core/router/app_router.dart | 10 + .../data/in_site_notification.dart | 90 +++ .../data/in_site_notification_repository.dart | 50 ++ .../data/notification_channel.dart | 31 + .../pages/notification_inbox_page.dart | 364 +++++++++ .../pages/notification_preferences_page.dart | 241 ++++++ .../providers/notification_providers.dart | 37 +- .../force_read_notification_dialog.dart | 179 +++++ .../presentation/pages/profile_page.dart | 62 ++ packages/gateway/config/kong.yml | 65 ++ .../services/auth-service/src/auth.module.ts | 7 +- .../entities/tenant-tag-assignment.entity.ts | 16 + .../src/domain/entities/tenant-tag.entity.ts | 25 + .../rest/controllers/tenant-tag.controller.ts | 194 +++++ .../notification-service/package.json | 31 + .../services/event-trigger.service.ts | 157 ++++ .../entities/notification-read.entity.ts | 33 + .../notification-tenant-target.entity.ts | 17 + .../domain/entities/notification.entity.ts | 106 +++ .../repositories/campaign.repository.ts | 187 +++++ .../repositories/channel.repository.ts | 127 ++++ .../repositories/notification.repository.ts | 364 +++++++++ .../repositories/segment.repository.ts | 93 +++ .../controllers/campaign-admin.controller.ts | 168 +++++ .../notification-admin.controller.ts | 150 ++++ .../notification-channel.controller.ts | 133 ++++ .../notification-user.controller.ts | 73 ++ .../controllers/segment-admin.controller.ts | 110 +++ .../services/notification-service/src/main.ts | 30 + .../src/notification.module.ts | 48 ++ .../notification-service/tsconfig.json | 20 + .../007-create-notification-tables.sql | 55 ++ .../migrations/008-create-tenant-tags.sql | 55 ++ .../009-create-notification-channels.sql | 53 ++ .../010-create-notification-campaigns.sql | 77 ++ 48 files changed, 5879 insertions(+), 4 deletions(-) create mode 100644 it0-web-admin/src/app/(admin)/campaigns/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/notification-channels/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/notifications/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/segments/page.tsx create mode 100644 it0-web-admin/src/app/(admin)/tenant-tags/page.tsx create mode 100644 it0-web-admin/src/domain/entities/notification.ts create mode 100644 it0-web-admin/src/domain/entities/tenant-tag.ts create mode 100644 it0-web-admin/src/infrastructure/repositories/api-notification.repository.ts create mode 100644 it0-web-admin/src/infrastructure/repositories/api-tenant-tag.repository.ts create mode 100644 it0_app/lib/features/notifications/data/in_site_notification.dart create mode 100644 it0_app/lib/features/notifications/data/in_site_notification_repository.dart create mode 100644 it0_app/lib/features/notifications/data/notification_channel.dart create mode 100644 it0_app/lib/features/notifications/presentation/pages/notification_inbox_page.dart create mode 100644 it0_app/lib/features/notifications/presentation/pages/notification_preferences_page.dart create mode 100644 it0_app/lib/features/notifications/presentation/widgets/force_read_notification_dialog.dart create mode 100644 packages/services/auth-service/src/domain/entities/tenant-tag-assignment.entity.ts create mode 100644 packages/services/auth-service/src/domain/entities/tenant-tag.entity.ts create mode 100644 packages/services/auth-service/src/interfaces/rest/controllers/tenant-tag.controller.ts create mode 100644 packages/services/notification-service/package.json create mode 100644 packages/services/notification-service/src/application/services/event-trigger.service.ts create mode 100644 packages/services/notification-service/src/domain/entities/notification-read.entity.ts create mode 100644 packages/services/notification-service/src/domain/entities/notification-tenant-target.entity.ts create mode 100644 packages/services/notification-service/src/domain/entities/notification.entity.ts create mode 100644 packages/services/notification-service/src/infrastructure/repositories/campaign.repository.ts create mode 100644 packages/services/notification-service/src/infrastructure/repositories/channel.repository.ts create mode 100644 packages/services/notification-service/src/infrastructure/repositories/notification.repository.ts create mode 100644 packages/services/notification-service/src/infrastructure/repositories/segment.repository.ts create mode 100644 packages/services/notification-service/src/interfaces/rest/controllers/campaign-admin.controller.ts create mode 100644 packages/services/notification-service/src/interfaces/rest/controllers/notification-admin.controller.ts create mode 100644 packages/services/notification-service/src/interfaces/rest/controllers/notification-channel.controller.ts create mode 100644 packages/services/notification-service/src/interfaces/rest/controllers/notification-user.controller.ts create mode 100644 packages/services/notification-service/src/interfaces/rest/controllers/segment-admin.controller.ts create mode 100644 packages/services/notification-service/src/main.ts create mode 100644 packages/services/notification-service/src/notification.module.ts create mode 100644 packages/services/notification-service/tsconfig.json create mode 100644 packages/shared/database/migrations/007-create-notification-tables.sql create mode 100644 packages/shared/database/migrations/008-create-tenant-tags.sql create mode 100644 packages/shared/database/migrations/009-create-notification-channels.sql create mode 100644 packages/shared/database/migrations/010-create-notification-campaigns.sql diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 8fd7303..55e4ac6 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -71,6 +71,8 @@ services: condition: service_healthy referral-service: condition: service_healthy + notification-service: + condition: service_healthy healthcheck: test: ["CMD", "kong", "health"] interval: 10s @@ -469,6 +471,37 @@ services: networks: - it0-network + notification-service: + build: + context: ../.. + dockerfile: Dockerfile.service + args: + SERVICE_NAME: notification-service + SERVICE_PORT: 3013 + container_name: it0-notification-service + restart: unless-stopped + ports: + - "13013:3013" + environment: + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USERNAME=${POSTGRES_USER:-it0} + - DB_PASSWORD=${POSTGRES_PASSWORD:-it0_dev} + - DB_DATABASE=${POSTGRES_DB:-it0} + - NOTIFICATION_SERVICE_PORT=3013 + - JWT_SECRET=${JWT_SECRET:-dev-jwt-secret} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3013/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + depends_on: + postgres: + condition: service_healthy + networks: + - it0-network + # ===== LiveKit Infrastructure ===== # NOTE: livekit-server, voice-agent, voice-service use host networking # to eliminate docker-proxy overhead for real-time audio (WebRTC UDP). diff --git a/it0-web-admin/src/app/(admin)/campaigns/page.tsx b/it0-web-admin/src/app/(admin)/campaigns/page.tsx new file mode 100644 index 0000000..b2fd1e0 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/campaigns/page.tsx @@ -0,0 +1,488 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Megaphone, Plus, RefreshCw, Pencil, Trash2, X, BarChart2, Play, Ban } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'RUNNING' | 'COMPLETED' | 'CANCELLED' | 'FAILED'; +type ScheduleType = 'ONCE' | 'RECURRING'; + +interface Campaign { + id: string; + name: string; + description: string | null; + status: CampaignStatus; + notificationId: string | null; + scheduleType: ScheduleType; + scheduledAt: string | null; + cronExpr: string | null; + timezone: string; + nextRunAt: string | null; + lastRunAt: string | null; + targetCount: number; + sentCount: number; + readCount: number; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +interface CampaignStats { + targetCount: number; + sentCount: number; + readCount: number; + readRate: number; + executions: any[]; +} + +function getAuthHeaders(): HeadersInit { + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + return { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' }; +} + +const BASE = '/api/proxy/api/v1/notifications/campaigns'; + +async function listCampaigns(params: { status?: string; limit?: number; offset?: number }) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit != null) q.set('limit', String(params.limit)); + if (params.offset != null) q.set('offset', String(params.offset)); + const res = await fetch(`${BASE}?${q}`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json() as Promise<{ items: Campaign[]; total: number }>; +} + +async function createCampaign(dto: any) { + const res = await fetch(BASE, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function updateCampaign(id: string, dto: any) { + const res = await fetch(`${BASE}/${id}`, { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function scheduleCampaign(id: string, dto: { scheduledAt?: string; cronExpr?: string; timezone?: string }) { + const res = await fetch(`${BASE}/${id}/schedule`, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function cancelCampaign(id: string) { + const res = await fetch(`${BASE}/${id}/cancel`, { method: 'POST', headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function deleteCampaign(id: string) { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); +} + +async function getCampaignStats(id: string): Promise { + const res = await fetch(`${BASE}/${id}/stats`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +/* ---------- Status badge ---------- */ + +const STATUS_STYLE: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + SCHEDULED: 'bg-blue-100 text-blue-700', + RUNNING: 'bg-yellow-100 text-yellow-700', + COMPLETED: 'bg-green-100 text-green-700', + CANCELLED: 'bg-gray-100 text-gray-500', + FAILED: 'bg-red-100 text-red-700', +}; +const STATUS_LABEL: Record = { + DRAFT: '草稿', SCHEDULED: '已排期', RUNNING: '执行中', + COMPLETED: '已完成', CANCELLED: '已取消', FAILED: '失败', +}; + +/* ---------- Stats Modal ---------- */ + +function StatsModal({ campaignId, name, onClose }: { campaignId: string; name: string; onClose: () => void }) { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getCampaignStats(campaignId) + .then(setStats) + .catch(console.error) + .finally(() => setLoading(false)); + }, [campaignId]); + + return ( +
+
+
+

{name} — 数据统计

+ +
+
+ {loading ? ( +

加载中…

+ ) : stats ? ( +
+
+ {[ + { label: '目标人数', value: stats.targetCount }, + { label: '已发送', value: stats.sentCount }, + { label: '已阅读', value: stats.readCount }, + ].map((item) => ( +
+

{item.value.toLocaleString()}

+

{item.label}

+
+ ))} +
+
+

{stats.readRate}%

+

阅读率

+
+ {stats.executions.length > 0 && ( +
+

最近执行记录

+
+ {stats.executions.map((ex: any, i: number) => ( +
+ {new Date(ex.started_at).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' })} + + {ex.status} + + {ex.sent_count} 发送 +
+ ))} +
+
+ )} +
+ ) : ( +

暂无数据

+ )} +
+
+
+ ); +} + +/* ---------- Campaign Modal ---------- */ + +function CampaignModal({ + initial, + onSave, + onClose, +}: { + initial?: Campaign; + onSave: (dto: any, id?: string) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [description, setDescription] = useState(initial?.description ?? ''); + const [notificationId, setNotificationId] = useState(initial?.notificationId ?? ''); + const [scheduleType, setScheduleType] = useState(initial?.scheduleType ?? 'ONCE'); + const [scheduledAt, setScheduledAt] = useState( + initial?.scheduledAt ? initial.scheduledAt.slice(0, 16) : '', + ); + const [cronExpr, setCronExpr] = useState(initial?.cronExpr ?? ''); + const [timezone, setTimezone] = useState(initial?.timezone ?? 'Asia/Shanghai'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + if (!name.trim()) { setError('活动名称不能为空'); return; } + setSaving(true); setError(''); + try { + await onSave({ + name: name.trim(), + description: description.trim() || undefined, + notificationId: notificationId.trim() || undefined, + scheduleType, + scheduledAt: scheduleType === 'ONCE' && scheduledAt ? new Date(scheduledAt).toISOString() : null, + cronExpr: scheduleType === 'RECURRING' ? cronExpr.trim() : null, + timezone, + }, initial?.id); + onClose(); + } catch (e: any) { + setError(e.message ?? '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

{initial ? '编辑活动' : '新建推送活动'}

+ +
+
+ {error &&

{error}

} + +
+ + setName(e.target.value)} maxLength={200} /> +
+ +
+ +