From b5e45c4532c5ecdaa506b6383cd771480ee18c27 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 24 Dec 2025 16:19:05 -0800 Subject: [PATCH] =?UTF-8?q?feat(user-profile):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=94=BB=E5=83=8F=E7=B3=BB=E7=BB=9F=E5=92=8C?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=AE=9A=E5=90=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 (admin-service): - 新增用户标签系统:标签分类、标签定义、用户标签分配 - 新增分类规则引擎:支持自动打标规则 - 新增人群包管理:支持复杂条件组合筛选用户 - 增强通知系统:支持按标签、按人群包、指定用户定向发送 - 新增自动标签同步定时任务 - Prisma Schema 扩展支持新数据模型 前端 (admin-web): - 通知管理页面新增 Tab 切换:通知列表、用户标签、人群包 - 用户标签管理:分类管理、标签 CRUD、颜色/类型配置 - 人群包管理:条件组编辑器、逻辑运算符配置 - 通知编辑器:支持按标签筛选和指定用户定向 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../services/admin-service/package-lock.json | 53 ++ backend/services/admin-service/package.json | 1 + .../admin-service/prisma/schema.prisma | 315 ++++++++- .../audience-segment.controller.ts | 273 ++++++++ .../classification-rule.controller.ts | 273 ++++++++ .../controllers/notification.controller.ts | 78 ++- .../api/controllers/user-tag.controller.ts | 481 +++++++++++++ .../api/dto/request/audience-segment.dto.ts | 114 +++ .../dto/request/classification-rule.dto.ts | 84 +++ .../src/api/dto/request/notification.dto.ts | 49 +- .../src/api/dto/request/user-tag.dto.ts | 338 +++++++++ .../api/dto/response/audience-segment.dto.ts | 85 +++ .../dto/response/classification-rule.dto.ts | 69 ++ .../src/api/dto/response/notification.dto.ts | 20 +- .../src/api/dto/response/user-tag.dto.ts | 183 +++++ .../services/admin-service/src/app.module.ts | 39 ++ .../services/audience-segment.service.ts | 484 +++++++++++++ .../services/rule-engine.service.ts | 317 +++++++++ .../services/user-tagging.service.ts | 427 ++++++++++++ .../entities/audience-segment.entity.ts | 222 ++++++ .../entities/classification-rule.entity.ts | 197 ++++++ .../domain/entities/notification.entity.ts | 99 ++- .../domain/entities/user-feature.entity.ts | 225 ++++++ .../src/domain/entities/user-tag.entity.ts | 441 ++++++++++++ .../audience-segment.repository.ts | 54 ++ .../classification-rule.repository.ts | 58 ++ .../repositories/user-tag.repository.ts | 111 +++ .../infrastructure/jobs/auto-tag-sync.job.ts | 136 ++++ .../mappers/notification.mapper.ts | 40 +- .../audience-segment.repository.impl.ts | 145 ++++ .../classification-rule.repository.impl.ts | 128 ++++ .../notification.repository.impl.ts | 193 +++++- .../repositories/user-tag.repository.impl.ts | 647 ++++++++++++++++++ .../notifications/notifications.module.scss | 95 +++ .../app/(dashboard)/notifications/page.tsx | 372 +++++++--- .../AudienceSegmentsTab.module.scss | 340 +++++++++ .../AudienceSegmentsTab.tsx | 433 ++++++++++++ .../AudienceSegmentsTab/index.ts | 2 + .../UserTagsTab/UserTagsTab.module.scss | 345 ++++++++++ .../notifications/UserTagsTab/UserTagsTab.tsx | 504 ++++++++++++++ .../notifications/UserTagsTab/index.ts | 2 + .../features/notifications/index.ts | 2 + .../src/infrastructure/api/endpoints.ts | 38 + .../src/services/audienceSegmentService.ts | 173 +++++ .../src/services/notificationService.ts | 26 +- .../admin-web/src/services/userTagService.ts | 250 +++++++ 46 files changed, 8803 insertions(+), 158 deletions(-) create mode 100644 backend/services/admin-service/src/api/controllers/audience-segment.controller.ts create mode 100644 backend/services/admin-service/src/api/controllers/classification-rule.controller.ts create mode 100644 backend/services/admin-service/src/api/controllers/user-tag.controller.ts create mode 100644 backend/services/admin-service/src/api/dto/request/audience-segment.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/request/classification-rule.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/request/user-tag.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/response/audience-segment.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/response/classification-rule.dto.ts create mode 100644 backend/services/admin-service/src/api/dto/response/user-tag.dto.ts create mode 100644 backend/services/admin-service/src/application/services/audience-segment.service.ts create mode 100644 backend/services/admin-service/src/application/services/rule-engine.service.ts create mode 100644 backend/services/admin-service/src/application/services/user-tagging.service.ts create mode 100644 backend/services/admin-service/src/domain/entities/audience-segment.entity.ts create mode 100644 backend/services/admin-service/src/domain/entities/classification-rule.entity.ts create mode 100644 backend/services/admin-service/src/domain/entities/user-feature.entity.ts create mode 100644 backend/services/admin-service/src/domain/entities/user-tag.entity.ts create mode 100644 backend/services/admin-service/src/domain/repositories/audience-segment.repository.ts create mode 100644 backend/services/admin-service/src/domain/repositories/classification-rule.repository.ts create mode 100644 backend/services/admin-service/src/domain/repositories/user-tag.repository.ts create mode 100644 backend/services/admin-service/src/infrastructure/jobs/auto-tag-sync.job.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/audience-segment.repository.impl.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/classification-rule.repository.impl.ts create mode 100644 backend/services/admin-service/src/infrastructure/persistence/repositories/user-tag.repository.impl.ts create mode 100644 frontend/admin-web/src/components/features/notifications/AudienceSegmentsTab/AudienceSegmentsTab.module.scss create mode 100644 frontend/admin-web/src/components/features/notifications/AudienceSegmentsTab/AudienceSegmentsTab.tsx create mode 100644 frontend/admin-web/src/components/features/notifications/AudienceSegmentsTab/index.ts create mode 100644 frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.module.scss create mode 100644 frontend/admin-web/src/components/features/notifications/UserTagsTab/UserTagsTab.tsx create mode 100644 frontend/admin-web/src/components/features/notifications/UserTagsTab/index.ts create mode 100644 frontend/admin-web/src/components/features/notifications/index.ts create mode 100644 frontend/admin-web/src/services/audienceSegmentService.ts create mode 100644 frontend/admin-web/src/services/userTagService.ts diff --git a/backend/services/admin-service/package-lock.json b/backend/services/admin-service/package-lock.json index 4aaef6b8..d339a7de 100644 --- a/backend/services/admin-service/package-lock.json +++ b/backend/services/admin-service/package-lock.json @@ -15,6 +15,7 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.1.17", "@prisma/client": "^5.7.0", @@ -1888,6 +1889,33 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", + "integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==", + "license": "MIT", + "dependencies": { + "cron": "3.2.1", + "uuid": "11.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -2451,6 +2479,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -4244,6 +4278,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz", + "integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.5.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7314,6 +7358,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json index 6b7b85c2..9f48d906 100644 --- a/backend/services/admin-service/package.json +++ b/backend/services/admin-service/package.json @@ -35,6 +35,7 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.1.17", "@prisma/client": "^5.7.0", diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 6d750e33..f27a32d6 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -56,6 +56,7 @@ model Notification { type NotificationType // 通知类型 priority NotificationPriority @default(NORMAL) // 优先级 targetType TargetType @default(ALL) // 目标用户类型 + targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑 imageUrl String? // 可选的图片URL linkUrl String? // 可选的跳转链接 isEnabled Boolean @default(true) // 是否启用 @@ -65,11 +66,14 @@ model Notification { updatedAt DateTime @updatedAt createdBy String // 创建人ID - // 用户已读记录 + // 关联 readRecords NotificationRead[] + targetTags NotificationTagTarget[] // BY_TAG 时使用 + targetUsers NotificationUserTarget[] // SPECIFIC 时使用 @@index([isEnabled, publishedAt]) @@index([type]) + @@index([targetType]) @@map("notifications") } @@ -106,9 +110,312 @@ enum NotificationPriority { /// 目标用户类型 enum TargetType { - ALL // 所有用户 - NEW_USER // 新用户 - VIP // VIP用户 + ALL // 所有用户 + BY_TAG // 按标签匹配 + SPECIFIC // 指定用户列表 +} + +/// 多标签匹配逻辑 +enum TargetLogic { + ANY // 匹配任一标签 + ALL // 匹配所有标签 +} + +/// 通知-标签关联 +model NotificationTagTarget { + id String @id @default(uuid()) + notificationId String @map("notification_id") + tagId String @map("tag_id") + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + tag UserTag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([notificationId, tagId]) + @@index([tagId]) + @@map("notification_tag_targets") +} + +/// 通知-用户关联 (指定用户) +model NotificationUserTarget { + id String @id @default(uuid()) + notificationId String @map("notification_id") + accountSequence String @map("account_sequence") @db.VarChar(12) + + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + + @@unique([notificationId, accountSequence]) + @@index([accountSequence]) + @@map("notification_user_targets") +} + +// ============================================================================= +// User Profile System (用户画像系统) - 面向通知 + 广告 +// ============================================================================= + +// ----------------------------------------------------------------------------- +// 标签分类 (Tag Category) - 标签的分组管理 +// ----------------------------------------------------------------------------- + +/// 标签分类 +model TagCategory { + id String @id @default(uuid()) + code String @unique @db.VarChar(50) // "lifecycle", "value", "behavior" + name String @db.VarChar(100) // "生命周期", "价值分层", "行为特征" + description String? @db.Text + sortOrder Int @default(0) @map("sort_order") + isEnabled Boolean @default(true) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tags UserTag[] + + @@index([code]) + @@index([isEnabled]) + @@map("tag_categories") +} + +// ----------------------------------------------------------------------------- +// 用户标签 (User Tag) - 增强版 +// ----------------------------------------------------------------------------- + +/// 标签类型 +enum TagType { + MANUAL // 手动打标 (管理员操作) + AUTO // 自动打标 (规则驱动) + COMPUTED // 计算型 (实时计算,不存储关联) + SYSTEM // 系统内置 (不可删除) +} + +/// 标签值类型 +enum TagValueType { + BOOLEAN // 布尔型: 有/无 + ENUM // 枚举型: 高/中/低 + NUMBER // 数值型: 0-100分 + STRING // 字符串型 +} + +/// 用户标签定义 +model UserTag { + id String @id @default(uuid()) + categoryId String? @map("category_id") + code String @unique @db.VarChar(50) // "vip", "new_user", "whale" + name String @db.VarChar(100) // "VIP用户", "新用户", "大客户" + description String? @db.Text + color String? @db.VarChar(20) // "#FF5722" + + type TagType @default(MANUAL) // 标签类型 + valueType TagValueType @default(BOOLEAN) @map("value_type") // 标签值类型 + + // 枚举型标签的可选值 + // 例如: ["高", "中", "低"] 或 ["活跃", "沉默", "流失"] + enumValues Json? @map("enum_values") + + // 关联的自动规则 (type=AUTO 时使用) + ruleId String? @unique @map("rule_id") + rule UserClassificationRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull) + + // 广告相关 + isAdvertisable Boolean @default(true) @map("is_advertisable") // 是否可用于广告定向 + estimatedUsers Int? @map("estimated_users") // 预估覆盖用户数 + + isEnabled Boolean @default(true) @map("is_enabled") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联 + category TagCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + assignments UserTagAssignment[] + notifications NotificationTagTarget[] + + @@index([categoryId]) + @@index([code]) + @@index([type]) + @@index([isEnabled]) + @@index([isAdvertisable]) + @@map("user_tags") +} + +// ----------------------------------------------------------------------------- +// 用户-标签关联 - 支持标签值 +// ----------------------------------------------------------------------------- + +/// 用户-标签关联 +model UserTagAssignment { + id String @id @default(uuid()) + accountSequence String @map("account_sequence") @db.VarChar(12) + tagId String @map("tag_id") + + // 标签值 (根据 valueType) + // BOOLEAN: null (存在即为true) + // ENUM: "高" / "中" / "低" + // NUMBER: "85" (字符串存储) + // STRING: 任意字符串 + value String? @db.VarChar(100) + + assignedAt DateTime @default(now()) @map("assigned_at") + assignedBy String? @map("assigned_by") // null=系统自动, 否则为管理员ID + expiresAt DateTime? @map("expires_at") // 可选过期时间 + source String? @db.VarChar(50) // 来源: "rule:xxx", "import", "manual", "kafka" + + tag UserTag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([accountSequence, tagId]) + @@index([accountSequence]) + @@index([tagId]) + @@index([value]) + @@index([expiresAt]) + @@map("user_tag_assignments") +} + +// ----------------------------------------------------------------------------- +// 用户分类规则 (Classification Rule) +// ----------------------------------------------------------------------------- + +/// 用户分类规则 +model UserClassificationRule { + id String @id @default(uuid()) + name String @db.VarChar(100) // "30天内新用户" + description String? @db.Text + + // 规则条件 (JSON) + // 示例: + // { + // "type": "AND", + // "rules": [ + // { "field": "registeredAt", "operator": "within_days", "value": 30 }, + // { "field": "kycStatus", "operator": "eq", "value": "VERIFIED" } + // ] + // } + conditions Json + + isEnabled Boolean @default(true) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // 关联的自动标签 + tag UserTag? + + @@map("user_classification_rules") +} + +// ----------------------------------------------------------------------------- +// 用户特征 (User Features) - 数值型指标 +// ----------------------------------------------------------------------------- + +/// 用户特征 - 计算后的用户画像指标 +model UserFeature { + id String @id @default(uuid()) + accountSequence String @unique @map("account_sequence") @db.VarChar(12) + + // RFM 模型 + rfmRecency Int? @map("rfm_recency") // 最近一次活跃距今天数 + rfmFrequency Int? @map("rfm_frequency") // 过去30天活跃天数 + rfmMonetary Decimal? @map("rfm_monetary") @db.Decimal(18, 2) // 累计消费金额 + rfmScore Int? @map("rfm_score") // RFM综合分 (0-100) + + // 活跃度 + activeLevel String? @map("active_level") @db.VarChar(20) // "高活跃", "中活跃", "低活跃", "沉默" + lastActiveAt DateTime? @map("last_active_at") + + // 价值分层 + valueLevel String? @map("value_level") @db.VarChar(20) // "高价值", "中价值", "低价值", "潜力" + lifetimeValue Decimal? @map("lifetime_value") @db.Decimal(18, 2) // 用户生命周期价值 LTV + + // 生命周期 + lifecycleStage String? @map("lifecycle_stage") @db.VarChar(20) // "新用户", "成长期", "成熟期", "衰退期", "流失" + + // 自定义特征 (JSON扩展) + customFeatures Json? @map("custom_features") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([rfmScore]) + @@index([activeLevel]) + @@index([valueLevel]) + @@index([lifecycleStage]) + @@map("user_features") +} + +// ----------------------------------------------------------------------------- +// 人群包 (Audience Segment) - 复杂定向 +// ----------------------------------------------------------------------------- + +/// 人群包用途 +enum SegmentUsageType { + GENERAL // 通用 + NOTIFICATION // 通知定向 + ADVERTISING // 广告定向 + ANALYTICS // 数据分析 +} + +/// 人群包 - 多条件组合的用户群 +model AudienceSegment { + id String @id @default(uuid()) + name String @db.VarChar(100) // "高价值活跃用户" + description String? @db.Text + + // 定向条件 (JSON) + // { + // "type": "AND", + // "conditions": [ + // { "type": "tag", "tagCode": "vip", "operator": "eq", "value": true }, + // { "type": "feature", "field": "rfmScore", "operator": "gte", "value": 80 }, + // { "type": "profile", "field": "province", "operator": "in", "value": ["广东", "浙江"] } + // ] + // } + conditions Json + + // 预估数据 + estimatedUsers Int? @map("estimated_users") + lastCalculated DateTime? @map("last_calculated") + + // 用途 + usageType SegmentUsageType @default(GENERAL) @map("usage_type") + + isEnabled Boolean @default(true) @map("is_enabled") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String @map("created_by") + + @@index([usageType]) + @@index([isEnabled]) + @@map("audience_segments") +} + +// ----------------------------------------------------------------------------- +// 标签变更日志 (审计追踪) +// ----------------------------------------------------------------------------- + +/// 标签变更操作 +enum TagAction { + ASSIGN // 打标签 + UPDATE // 更新标签值 + REMOVE // 移除标签 + EXPIRE // 过期移除 +} + +/// 标签变更日志 +model UserTagLog { + id String @id @default(uuid()) + accountSequence String @map("account_sequence") @db.VarChar(12) + tagCode String @map("tag_code") @db.VarChar(50) + + action TagAction + oldValue String? @map("old_value") @db.VarChar(100) + newValue String? @map("new_value") @db.VarChar(100) + + reason String? @db.VarChar(200) // "规则触发", "管理员操作", "导入", "过期清理" + operatorId String? @map("operator_id") + + createdAt DateTime @default(now()) @map("created_at") + + @@index([accountSequence, createdAt]) + @@index([tagCode]) + @@index([action]) + @@index([createdAt]) + @@map("user_tag_logs") } // ============================================================================= diff --git a/backend/services/admin-service/src/api/controllers/audience-segment.controller.ts b/backend/services/admin-service/src/api/controllers/audience-segment.controller.ts new file mode 100644 index 00000000..f91cf504 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/audience-segment.controller.ts @@ -0,0 +1,273 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { AudienceSegmentService } from '../../application/services/audience-segment.service'; +import { AudienceSegmentEntity } from '../../domain/entities/audience-segment.entity'; +import { + CreateSegmentDto, + UpdateSegmentDto, + ListSegmentsDto, + TestSegmentDto, + EstimateSegmentDto, + GetSegmentUsersDto, +} from '../dto/request/audience-segment.dto'; +import { + SegmentResponseDto, + SegmentListResponseDto, + SegmentTestResultDto, + SegmentEstimateResultDto, + SegmentUsersResponseDto, + UserSegmentsResponseDto, +} from '../dto/response/audience-segment.dto'; + +/** + * 人群包管理控制器 + */ +@Controller('admin/segments') +export class AudienceSegmentController { + constructor(private readonly segmentService: AudienceSegmentService) {} + + /** + * 创建人群包 + */ + @Post() + async createSegment( + @Body() dto: CreateSegmentDto, + ): Promise { + const segment = await this.segmentService.createSegment({ + name: dto.name, + description: dto.description, + conditions: dto.conditions, + usageType: dto.usageType, + createdBy: 'admin', // TODO: 从认证信息获取 + }); + + return SegmentResponseDto.fromEntity(segment); + } + + /** + * 获取人群包列表 + */ + @Get() + async listSegments( + @Query() dto: ListSegmentsDto, + ): Promise { + const { items, total } = await this.segmentService.listSegments({ + usageType: dto.usageType, + isEnabled: dto.isEnabled, + limit: dto.limit, + offset: dto.offset, + }); + + return { + items: items.map(SegmentResponseDto.fromEntity), + total, + }; + } + + /** + * 获取人群包详情 + */ + @Get(':id') + async getSegment(@Param('id') id: string): Promise { + const segment = await this.segmentService.getSegment(id); + if (!segment) { + throw new NotFoundException(`Segment not found: ${id}`); + } + return SegmentResponseDto.fromEntity(segment); + } + + /** + * 更新人群包 + */ + @Put(':id') + async updateSegment( + @Param('id') id: string, + @Body() dto: UpdateSegmentDto, + ): Promise { + try { + const updated = await this.segmentService.updateSegment(id, dto); + return SegmentResponseDto.fromEntity(updated); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + throw new NotFoundException(error.message); + } + throw error; + } + } + + /** + * 删除人群包 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteSegment(@Param('id') id: string): Promise { + await this.segmentService.deleteSegment(id); + } + + /** + * 测试用户是否匹配人群包 + */ + @Post(':id/test') + async testSegment( + @Param('id') id: string, + @Body() dto: TestSegmentDto, + ): Promise { + const segment = await this.segmentService.getSegment(id); + if (!segment) { + throw new NotFoundException(`Segment not found: ${id}`); + } + + const matched = await this.segmentService.evaluateUser( + dto.accountSequence, + id, + ); + + return { + segmentId: id, + segmentName: segment.name, + accountSequence: dto.accountSequence, + matched, + }; + } + + /** + * 预估人群包用户数 + */ + @Post(':id/estimate') + async estimateSegment( + @Param('id') id: string, + ): Promise { + const segment = await this.segmentService.getSegment(id); + if (!segment) { + throw new NotFoundException(`Segment not found: ${id}`); + } + + const { accountSequences, total } = + await this.segmentService.findMatchingUsers(id, { limit: 10 }); + + return { + estimatedUsers: total, + sampleUsers: accountSequences, + }; + } + + /** + * 预估条件匹配用户数(不保存人群包) + */ + @Post('estimate') + async estimateConditions( + @Body() dto: EstimateSegmentDto, + ): Promise { + if (!AudienceSegmentEntity.validateConditions(dto.conditions)) { + throw new BadRequestException('Invalid segment conditions format'); + } + + // 创建临时人群包进行评估 + const tempSegment = await this.segmentService.createSegment({ + name: '__temp_estimate__', + conditions: dto.conditions, + createdBy: 'system', + }); + + try { + const { accountSequences, total } = + await this.segmentService.findMatchingUsers(tempSegment.id, { + limit: 10, + }); + + return { + estimatedUsers: total, + sampleUsers: accountSequences, + }; + } finally { + // 删除临时人群包 + await this.segmentService.deleteSegment(tempSegment.id); + } + } + + /** + * 获取人群包内的用户列表 + */ + @Get(':id/users') + async getSegmentUsers( + @Param('id') id: string, + @Query() dto: GetSegmentUsersDto, + ): Promise { + const segment = await this.segmentService.getSegment(id); + if (!segment) { + throw new NotFoundException(`Segment not found: ${id}`); + } + + const { accountSequences, total } = + await this.segmentService.findMatchingUsers(id, { + limit: dto.limit ?? 50, + offset: dto.offset ?? 0, + }); + + return { + segmentId: id, + users: accountSequences, + total, + }; + } + + /** + * 刷新人群包预估用户数 + */ + @Post(':id/refresh') + async refreshEstimate( + @Param('id') id: string, + ): Promise<{ estimatedUsers: number }> { + const segment = await this.segmentService.getSegment(id); + if (!segment) { + throw new NotFoundException(`Segment not found: ${id}`); + } + + const count = await this.segmentService.calculateEstimatedUsers(id); + return { estimatedUsers: count }; + } + + /** + * 刷新所有人群包的预估用户数 + */ + @Post('refresh-all') + async refreshAllEstimates(): Promise<{ success: boolean }> { + await this.segmentService.recalculateAllEstimatedUsers(); + return { success: true }; + } + + /** + * 获取用户匹配的所有人群包 + */ + @Get('user/:accountSequence') + async getUserSegments( + @Param('accountSequence') accountSequence: string, + @Query('usageType') usageType?: string, + ): Promise { + const results = await this.segmentService.evaluateUserSegments( + accountSequence, + usageType as any, + ); + + return { + accountSequence, + segments: results.map((r) => ({ + segmentId: r.segmentId, + segmentName: r.segmentName, + matched: r.matched, + })), + }; + } +} diff --git a/backend/services/admin-service/src/api/controllers/classification-rule.controller.ts b/backend/services/admin-service/src/api/controllers/classification-rule.controller.ts new file mode 100644 index 00000000..c5f1ca04 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/classification-rule.controller.ts @@ -0,0 +1,273 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + CLASSIFICATION_RULE_REPOSITORY, + ClassificationRuleRepository, +} from '../../domain/repositories/classification-rule.repository'; +import { + USER_TAG_REPOSITORY, + UserTagRepository, +} from '../../domain/repositories/user-tag.repository'; +import { ClassificationRuleEntity } from '../../domain/entities/classification-rule.entity'; +import { RuleEngineService } from '../../application/services/rule-engine.service'; +import { + CreateRuleDto, + UpdateRuleDto, + ListRulesDto, + TestRuleDto, + EstimateRuleDto, +} from '../dto/request/classification-rule.dto'; +import { + RuleResponseDto, + RuleListResponseDto, + RuleTestResultDto, + RuleEstimateResultDto, +} from '../dto/response/classification-rule.dto'; + +/** + * 分类规则管理控制器 + */ +@Controller('admin/rules') +export class ClassificationRuleController { + constructor( + @Inject(CLASSIFICATION_RULE_REPOSITORY) + private readonly ruleRepository: ClassificationRuleRepository, + @Inject(USER_TAG_REPOSITORY) + private readonly tagRepository: UserTagRepository, + private readonly ruleEngine: RuleEngineService, + ) {} + + /** + * 创建规则 + */ + @Post() + async createRule(@Body() dto: CreateRuleDto): Promise { + // 验证条件格式 + if (!ClassificationRuleEntity.validateConditions(dto.conditions)) { + throw new BadRequestException('Invalid rule conditions format'); + } + + const rule = ClassificationRuleEntity.create({ + id: uuidv4(), + name: dto.name, + description: dto.description, + conditions: dto.conditions, + }); + + const saved = await this.ruleRepository.save(rule); + return RuleResponseDto.fromEntity(saved); + } + + /** + * 获取规则列表 + */ + @Get() + async listRules(@Query() dto: ListRulesDto): Promise { + const [rules, total] = await Promise.all([ + this.ruleRepository.findAll({ + isEnabled: dto.isEnabled, + limit: dto.limit, + offset: dto.offset, + }), + this.ruleRepository.count({ isEnabled: dto.isEnabled }), + ]); + + // 获取关联的标签信息 + const linkedRules = await this.ruleRepository.findLinkedRules(); + const linkedMap = new Map( + linkedRules.map((r) => [r.rule.id, { tagId: r.tagId, tagCode: r.tagCode }]), + ); + + return { + items: rules.map((rule) => + RuleResponseDto.fromEntity(rule, linkedMap.get(rule.id)), + ), + total, + }; + } + + /** + * 获取规则详情 + */ + @Get(':id') + async getRule(@Param('id') id: string): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + // 查找关联的标签 + const tags = await this.tagRepository.findAllTags(); + const linkedTag = tags.find((t) => t.ruleId === id); + + return RuleResponseDto.fromEntity( + rule, + linkedTag ? { tagId: linkedTag.id, tagCode: linkedTag.code } : null, + ); + } + + /** + * 更新规则 + */ + @Put(':id') + async updateRule( + @Param('id') id: string, + @Body() dto: UpdateRuleDto, + ): Promise { + const existing = await this.ruleRepository.findById(id); + if (!existing) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + if (dto.conditions) { + if (!ClassificationRuleEntity.validateConditions(dto.conditions)) { + throw new BadRequestException('Invalid rule conditions format'); + } + } + + const updated = existing.update(dto); + const saved = await this.ruleRepository.save(updated); + + // 查找关联的标签 + const tags = await this.tagRepository.findAllTags(); + const linkedTag = tags.find((t) => t.ruleId === id); + + return RuleResponseDto.fromEntity( + saved, + linkedTag ? { tagId: linkedTag.id, tagCode: linkedTag.code } : null, + ); + } + + /** + * 删除规则 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteRule(@Param('id') id: string): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + await this.ruleRepository.delete(id); + } + + /** + * 启用规则 + */ + @Post(':id/enable') + async enableRule(@Param('id') id: string): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + const enabled = rule.enable(); + const saved = await this.ruleRepository.save(enabled); + return RuleResponseDto.fromEntity(saved); + } + + /** + * 禁用规则 + */ + @Post(':id/disable') + async disableRule(@Param('id') id: string): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + const disabled = rule.disable(); + const saved = await this.ruleRepository.save(disabled); + return RuleResponseDto.fromEntity(saved); + } + + /** + * 测试规则匹配 + */ + @Post(':id/test') + async testRule( + @Param('id') id: string, + @Body() dto: TestRuleDto, + ): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + const result = await this.ruleEngine.evaluateUserRules(dto.accountSequence); + const ruleResult = result.matchedRules.find((r) => r.ruleId === id); + + return { + ruleId: id, + ruleName: rule.name, + accountSequence: dto.accountSequence, + matched: ruleResult?.matched ?? false, + evaluatedConditions: [], // TODO: 详细的条件评估结果 + }; + } + + /** + * 预估规则匹配用户数 + */ + @Post(':id/estimate') + async estimateRule( + @Param('id') id: string, + ): Promise { + const rule = await this.ruleRepository.findById(id); + if (!rule) { + throw new NotFoundException(`Rule not found: ${id}`); + } + + const { accountSequences, total } = await this.ruleEngine.findMatchingUsers( + rule, + { limit: 10 }, + ); + + return { + estimatedUsers: total, + sampleUsers: accountSequences, + }; + } + + /** + * 预估条件匹配用户数(不保存规则) + */ + @Post('estimate') + async estimateConditions( + @Body() dto: EstimateRuleDto, + ): Promise { + if (!ClassificationRuleEntity.validateConditions(dto.conditions)) { + throw new BadRequestException('Invalid rule conditions format'); + } + + const tempRule = ClassificationRuleEntity.create({ + id: 'temp', + name: 'temp', + conditions: dto.conditions, + }); + + const { accountSequences, total } = await this.ruleEngine.findMatchingUsers( + tempRule, + { limit: 10 }, + ); + + return { + estimatedUsers: total, + sampleUsers: accountSequences, + }; + } +} diff --git a/backend/services/admin-service/src/api/controllers/notification.controller.ts b/backend/services/admin-service/src/api/controllers/notification.controller.ts index 27ec2633..594486eb 100644 --- a/backend/services/admin-service/src/api/controllers/notification.controller.ts +++ b/backend/services/admin-service/src/api/controllers/notification.controller.ts @@ -10,13 +10,18 @@ import { HttpCode, HttpStatus, Inject, + NotFoundException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { NOTIFICATION_REPOSITORY, NotificationRepository, } from '../../domain/repositories/notification.repository'; -import { NotificationEntity } from '../../domain/entities/notification.entity'; +import { + NotificationEntity, + NotificationTarget, + TargetType, +} from '../../domain/entities/notification.entity'; import { CreateNotificationDto, UpdateNotificationDto, @@ -46,13 +51,27 @@ export class AdminNotificationController { */ @Post() async create(@Body() dto: CreateNotificationDto): Promise { + // 构建目标配置 + let targetConfig: NotificationTarget | null = null; + const targetType = dto.targetType ?? TargetType.ALL; + + if (dto.targetConfig || targetType !== TargetType.ALL) { + targetConfig = { + type: targetType, + tagIds: dto.targetConfig?.tagIds, + segmentId: dto.targetConfig?.segmentId, + accountSequences: dto.targetConfig?.accountSequences, + }; + } + const notification = NotificationEntity.create({ id: uuidv4(), title: dto.title, content: dto.content, type: dto.type, priority: dto.priority, - targetType: dto.targetType, + targetType, + targetConfig, imageUrl: dto.imageUrl, linkUrl: dto.linkUrl, publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null, @@ -71,7 +90,7 @@ export class AdminNotificationController { async findOne(@Param('id') id: string): Promise { const notification = await this.notificationRepo.findById(id); if (!notification) { - throw new Error('Notification not found'); + throw new NotFoundException('Notification not found'); } return NotificationResponseDto.fromEntity(notification); } @@ -99,33 +118,48 @@ export class AdminNotificationController { ): Promise { const existing = await this.notificationRepo.findById(id); if (!existing) { - throw new Error('Notification not found'); + throw new NotFoundException('Notification not found'); } - const updated = new NotificationEntity( - existing.id, - dto.title ?? existing.title, - dto.content ?? existing.content, - dto.type ?? existing.type, - dto.priority ?? existing.priority, - dto.targetType ?? existing.targetType, - dto.imageUrl !== undefined ? dto.imageUrl : existing.imageUrl, - dto.linkUrl !== undefined ? dto.linkUrl : existing.linkUrl, - dto.isEnabled ?? existing.isEnabled, - dto.publishedAt !== undefined + // 构建目标配置 + let targetConfig: NotificationTarget | null | undefined = undefined; + if (dto.targetConfig !== undefined || dto.targetType !== undefined) { + const targetType = dto.targetType ?? existing.targetType; + if (dto.targetConfig || targetType !== TargetType.ALL) { + targetConfig = { + type: targetType, + tagIds: dto.targetConfig?.tagIds ?? existing.targetConfig?.tagIds, + segmentId: dto.targetConfig?.segmentId ?? existing.targetConfig?.segmentId, + accountSequences: + dto.targetConfig?.accountSequences ?? + existing.targetConfig?.accountSequences, + }; + } else { + targetConfig = null; + } + } + + const updated = existing.update({ + title: dto.title, + content: dto.content, + type: dto.type, + priority: dto.priority, + targetType: dto.targetType, + targetConfig, + imageUrl: dto.imageUrl, + linkUrl: dto.linkUrl, + isEnabled: dto.isEnabled, + publishedAt: dto.publishedAt !== undefined ? dto.publishedAt ? new Date(dto.publishedAt) : null - : existing.publishedAt, - dto.expiresAt !== undefined + : undefined, + expiresAt: dto.expiresAt !== undefined ? dto.expiresAt ? new Date(dto.expiresAt) : null - : existing.expiresAt, - existing.createdAt, - new Date(), - existing.createdBy, - ); + : undefined, + }); const saved = await this.notificationRepo.save(updated); return NotificationResponseDto.fromEntity(saved); diff --git a/backend/services/admin-service/src/api/controllers/user-tag.controller.ts b/backend/services/admin-service/src/api/controllers/user-tag.controller.ts new file mode 100644 index 00000000..f9ff188e --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/user-tag.controller.ts @@ -0,0 +1,481 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + USER_TAG_REPOSITORY, + UserTagRepository, +} from '../../domain/repositories/user-tag.repository'; +import { + TagCategoryEntity, + UserTagEntity, + TagType, +} from '../../domain/entities/user-tag.entity'; +import { UserTaggingService } from '../../application/services/user-tagging.service'; +import { RuleEngineService } from '../../application/services/rule-engine.service'; +import { + CreateTagCategoryDto, + UpdateTagCategoryDto, + CreateTagDto, + UpdateTagDto, + ListTagsDto, + LinkRuleToTagDto, + AssignTagToUserDto, + BatchAssignTagDto, + RemoveTagFromUserDto, + GetUserTagsDto, + GetTagUsersDto, + SyncUserAutoTagsDto, + BatchSyncAutoTagsDto, + ListTagLogsDto, +} from '../dto/request/user-tag.dto'; +import { + TagCategoryResponseDto, + TagResponseDto, + TagListResponseDto, + UserTagListResponseDto, + TagUserListResponseDto, + TagLogResponseDto, + TagSyncResultResponseDto, + BatchSyncResultResponseDto, + BatchOperationResultDto, +} from '../dto/response/user-tag.dto'; + +/** + * 标签管理控制器 + */ +@Controller('admin/tags') +export class UserTagController { + constructor( + @Inject(USER_TAG_REPOSITORY) + private readonly tagRepository: UserTagRepository, + private readonly taggingService: UserTaggingService, + private readonly ruleEngine: RuleEngineService, + ) {} + + // ===================== + // 标签分类管理 + // ===================== + + /** + * 创建标签分类 + */ + @Post('categories') + async createCategory( + @Body() dto: CreateTagCategoryDto, + ): Promise { + const existing = await this.tagRepository.findCategoryByCode(dto.code); + if (existing) { + throw new BadRequestException(`Category code already exists: ${dto.code}`); + } + + const category = TagCategoryEntity.create({ + id: uuidv4(), + code: dto.code, + name: dto.name, + description: dto.description, + sortOrder: dto.sortOrder, + }); + + const saved = await this.tagRepository.saveCategory(category); + return TagCategoryResponseDto.fromEntity(saved); + } + + /** + * 获取所有标签分类 + */ + @Get('categories') + async listCategories( + @Query('isEnabled') isEnabled?: string, + ): Promise { + const categories = await this.tagRepository.findAllCategories({ + isEnabled: isEnabled === undefined ? undefined : isEnabled === 'true', + }); + return categories.map(TagCategoryResponseDto.fromEntity); + } + + /** + * 获取标签分类详情 + */ + @Get('categories/:id') + async getCategory(@Param('id') id: string): Promise { + const category = await this.tagRepository.findCategoryById(id); + if (!category) { + throw new NotFoundException(`Category not found: ${id}`); + } + return TagCategoryResponseDto.fromEntity(category); + } + + /** + * 更新标签分类 + */ + @Put('categories/:id') + async updateCategory( + @Param('id') id: string, + @Body() dto: UpdateTagCategoryDto, + ): Promise { + const existing = await this.tagRepository.findCategoryById(id); + if (!existing) { + throw new NotFoundException(`Category not found: ${id}`); + } + + const updated = existing.update(dto); + const saved = await this.tagRepository.saveCategory(updated); + return TagCategoryResponseDto.fromEntity(saved); + } + + /** + * 删除标签分类 + */ + @Delete('categories/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteCategory(@Param('id') id: string): Promise { + await this.tagRepository.deleteCategory(id); + } + + // ===================== + // 标签定义管理 + // ===================== + + /** + * 创建标签 + */ + @Post() + async createTag(@Body() dto: CreateTagDto): Promise { + const existing = await this.tagRepository.findTagByCode(dto.code); + if (existing) { + throw new BadRequestException(`Tag code already exists: ${dto.code}`); + } + + const tag = UserTagEntity.create({ + id: uuidv4(), + code: dto.code, + name: dto.name, + categoryId: dto.categoryId, + description: dto.description, + color: dto.color, + type: dto.type, + valueType: dto.valueType, + enumValues: dto.enumValues, + isAdvertisable: dto.isAdvertisable, + sortOrder: dto.sortOrder, + }); + + const saved = await this.tagRepository.saveTag(tag); + return TagResponseDto.fromEntity(saved); + } + + /** + * 获取标签列表 + */ + @Get() + async listTags(@Query() dto: ListTagsDto): Promise { + const [items, total] = await Promise.all([ + this.tagRepository.findAllTags({ + categoryId: dto.categoryId, + type: dto.type, + isEnabled: dto.isEnabled, + isAdvertisable: dto.isAdvertisable, + limit: dto.limit, + offset: dto.offset, + }), + this.tagRepository.countTags({ + categoryId: dto.categoryId, + type: dto.type, + isEnabled: dto.isEnabled, + }), + ]); + + return { + items: items.map(TagResponseDto.fromEntity), + total, + }; + } + + /** + * 获取标签详情 + */ + @Get(':id') + async getTag(@Param('id') id: string): Promise { + const tag = await this.tagRepository.findTagById(id); + if (!tag) { + throw new NotFoundException(`Tag not found: ${id}`); + } + return TagResponseDto.fromEntity(tag); + } + + /** + * 更新标签 + */ + @Put(':id') + async updateTag( + @Param('id') id: string, + @Body() dto: UpdateTagDto, + ): Promise { + const existing = await this.tagRepository.findTagById(id); + if (!existing) { + throw new NotFoundException(`Tag not found: ${id}`); + } + + const updated = existing.update(dto); + const saved = await this.tagRepository.saveTag(updated); + return TagResponseDto.fromEntity(saved); + } + + /** + * 删除标签 + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteTag(@Param('id') id: string): Promise { + const tag = await this.tagRepository.findTagById(id); + if (!tag) { + throw new NotFoundException(`Tag not found: ${id}`); + } + if (!tag.canDelete()) { + throw new BadRequestException('System tags cannot be deleted'); + } + await this.tagRepository.deleteTag(id); + } + + /** + * 关联规则到标签 + */ + @Post(':id/link-rule') + async linkRule( + @Param('id') id: string, + @Body() dto: LinkRuleToTagDto, + ): Promise { + const tag = await this.tagRepository.findTagById(id); + if (!tag) { + throw new NotFoundException(`Tag not found: ${id}`); + } + + const linked = tag.linkRule(dto.ruleId); + const saved = await this.tagRepository.saveTag(linked); + return TagResponseDto.fromEntity(saved); + } + + /** + * 取消关联规则 + */ + @Post(':id/unlink-rule') + async unlinkRule(@Param('id') id: string): Promise { + const tag = await this.tagRepository.findTagById(id); + if (!tag) { + throw new NotFoundException(`Tag not found: ${id}`); + } + + const unlinked = tag.unlinkRule(); + const saved = await this.tagRepository.saveTag(unlinked); + return TagResponseDto.fromEntity(saved); + } + + /** + * 预估标签用户数 + */ + @Get(':id/estimate-users') + async estimateUsers(@Param('id') id: string): Promise<{ count: number }> { + const tag = await this.tagRepository.findTagById(id); + if (!tag) { + throw new NotFoundException(`Tag not found: ${id}`); + } + + // 直接使用计数方法 + const count = await this.tagRepository.countUsersByTagId(id); + return { count }; + } + + // ===================== + // 用户标签操作 + // ===================== + + /** + * 给用户打标签 + */ + @Post('assign') + @HttpCode(HttpStatus.OK) + async assignTag(@Body() dto: AssignTagToUserDto): Promise<{ success: boolean }> { + await this.taggingService.assignTag({ + accountSequence: dto.accountSequence, + tagId: dto.tagId, + value: dto.value, + assignedBy: 'admin', // TODO: 从认证信息获取 + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + reason: dto.reason, + }); + return { success: true }; + } + + /** + * 批量打标签 + */ + @Post('batch-assign') + async batchAssign( + @Body() dto: BatchAssignTagDto, + ): Promise { + return this.taggingService.batchAssignTag({ + accountSequences: dto.accountSequences, + tagId: dto.tagId, + value: dto.value, + assignedBy: 'admin', // TODO: 从认证信息获取 + reason: dto.reason, + }); + } + + /** + * 移除用户标签 + */ + @Post('remove') + @HttpCode(HttpStatus.OK) + async removeTag( + @Body() dto: RemoveTagFromUserDto, + ): Promise<{ success: boolean }> { + await this.taggingService.removeTag({ + accountSequence: dto.accountSequence, + tagId: dto.tagId, + operatorId: 'admin', // TODO: 从认证信息获取 + reason: dto.reason, + }); + return { success: true }; + } + + /** + * 获取用户的所有标签 + */ + @Get('user/:accountSequence') + async getUserTags( + @Param('accountSequence') accountSequence: string, + ): Promise { + const tags = await this.taggingService.getUserTags(accountSequence); + return { + accountSequence, + tags: tags.map((t) => ({ + tagId: t.tagId, + tagCode: t.tagCode, + tagName: t.tagName, + value: t.value, + isAuto: t.isAuto, + assignedAt: t.assignedAt.toISOString(), + })), + }; + } + + /** + * 获取标签下的用户 + */ + @Get(':id/users') + async getTagUsers( + @Param('id') id: string, + @Query() dto: GetTagUsersDto, + ): Promise { + const [assignments, total] = await Promise.all([ + this.tagRepository.findUsersByTagId(id, { + value: dto.value, + limit: dto.limit ?? 50, + offset: dto.offset ?? 0, + }), + this.tagRepository.countUsersByTagId(id, dto.value), + ]); + + return { + tagId: id, + users: assignments.map((a) => ({ + accountSequence: a.accountSequence, + value: a.value, + assignedAt: a.assignedAt.toISOString(), + assignedBy: a.assignedBy, + source: a.source, + })), + total, + }; + } + + // ===================== + // 自动标签同步 + // ===================== + + /** + * 同步单个用户的自动标签 + */ + @Post('sync-user') + async syncUserTags( + @Body() dto: SyncUserAutoTagsDto, + ): Promise { + return this.taggingService.syncUserAutoTags(dto.accountSequence); + } + + /** + * 批量同步自动标签 + */ + @Post('batch-sync') + async batchSyncTags( + @Body() dto: BatchSyncAutoTagsDto, + ): Promise { + return this.taggingService.batchSyncAutoTags(dto.accountSequences); + } + + /** + * 同步所有用户的自动标签 + */ + @Post('sync-all') + async syncAllTags(): Promise { + return this.taggingService.syncAllAutoTags(); + } + + /** + * 清理过期标签 + */ + @Post('clean-expired') + async cleanExpiredTags(): Promise<{ count: number }> { + const count = await this.taggingService.cleanExpiredTags(); + return { count }; + } + + // ===================== + // 标签变更日志 + // ===================== + + /** + * 查询标签变更日志 + */ + @Get('logs') + async listLogs(@Query() dto: ListTagLogsDto): Promise<{ + items: TagLogResponseDto[]; + total: number; + }> { + const [logs, total] = await Promise.all([ + this.tagRepository.findLogs({ + accountSequence: dto.accountSequence, + tagCode: dto.tagCode, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + limit: dto.limit ?? 50, + offset: dto.offset ?? 0, + }), + this.tagRepository.countLogs({ + accountSequence: dto.accountSequence, + tagCode: dto.tagCode, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + }), + ]); + + return { + items: logs.map(TagLogResponseDto.fromEntity), + total, + }; + } +} diff --git a/backend/services/admin-service/src/api/dto/request/audience-segment.dto.ts b/backend/services/admin-service/src/api/dto/request/audience-segment.dto.ts new file mode 100644 index 00000000..8d618b1e --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/audience-segment.dto.ts @@ -0,0 +1,114 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsInt, + IsObject, + IsEnum, + Min, + Max, +} from 'class-validator'; +import { + SegmentConditionGroup, + SegmentUsageType, +} from '../../../domain/entities/audience-segment.entity'; + +/** + * 创建人群包 + */ +export class CreateSegmentDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsObject() + conditions: SegmentConditionGroup; + + @IsOptional() + @IsEnum(SegmentUsageType) + usageType?: SegmentUsageType; +} + +/** + * 更新人群包 + */ +export class UpdateSegmentDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsObject() + conditions?: SegmentConditionGroup; + + @IsOptional() + @IsEnum(SegmentUsageType) + usageType?: SegmentUsageType; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} + +/** + * 查询人群包列表 + */ +export class ListSegmentsDto { + @IsOptional() + @IsEnum(SegmentUsageType) + usageType?: SegmentUsageType; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +/** + * 测试用户是否匹配人群包 + */ +export class TestSegmentDto { + @IsString() + accountSequence: string; +} + +/** + * 预估人群包用户数 + */ +export class EstimateSegmentDto { + @IsObject() + conditions: SegmentConditionGroup; +} + +/** + * 获取人群包用户列表 + */ +export class GetSegmentUsersDto { + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} diff --git a/backend/services/admin-service/src/api/dto/request/classification-rule.dto.ts b/backend/services/admin-service/src/api/dto/request/classification-rule.dto.ts new file mode 100644 index 00000000..61f19c14 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/classification-rule.dto.ts @@ -0,0 +1,84 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsInt, + IsObject, + Min, + Max, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { RuleConditions } from '../../../domain/entities/classification-rule.entity'; + +/** + * 创建分类规则 + */ +export class CreateRuleDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsObject() + conditions: RuleConditions; +} + +/** + * 更新分类规则 + */ +export class UpdateRuleDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsObject() + conditions?: RuleConditions; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} + +/** + * 查询规则列表 + */ +export class ListRulesDto { + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +/** + * 测试规则 + */ +export class TestRuleDto { + @IsString() + accountSequence: string; +} + +/** + * 预估规则匹配用户数 + */ +export class EstimateRuleDto { + @IsObject() + conditions: RuleConditions; +} diff --git a/backend/services/admin-service/src/api/dto/request/notification.dto.ts b/backend/services/admin-service/src/api/dto/request/notification.dto.ts index e68fadfd..aa10124a 100644 --- a/backend/services/admin-service/src/api/dto/request/notification.dto.ts +++ b/backend/services/admin-service/src/api/dto/request/notification.dto.ts @@ -1,5 +1,40 @@ -import { IsString, IsOptional, IsEnum, IsBoolean, IsDateString, IsInt, Min, Max } from 'class-validator'; -import { NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity'; +import { + IsString, + IsOptional, + IsEnum, + IsBoolean, + IsDateString, + IsInt, + IsArray, + Min, + Max, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + NotificationType, + NotificationPriority, + TargetType, +} from '../../../domain/entities/notification.entity'; + +/** + * 目标配置 DTO + */ +export class TargetConfigDto { + @IsOptional() + @IsArray() + @IsString({ each: true }) + tagIds?: string[]; + + @IsOptional() + @IsString() + segmentId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + accountSequences?: string[]; +} /** * 创建通知请求 @@ -22,6 +57,11 @@ export class CreateNotificationDto { @IsEnum(TargetType) targetType?: TargetType; + @IsOptional() + @ValidateNested() + @Type(() => TargetConfigDto) + targetConfig?: TargetConfigDto; + @IsOptional() @IsString() imageUrl?: string; @@ -63,6 +103,11 @@ export class UpdateNotificationDto { @IsEnum(TargetType) targetType?: TargetType; + @IsOptional() + @ValidateNested() + @Type(() => TargetConfigDto) + targetConfig?: TargetConfigDto; + @IsOptional() @IsString() imageUrl?: string; diff --git a/backend/services/admin-service/src/api/dto/request/user-tag.dto.ts b/backend/services/admin-service/src/api/dto/request/user-tag.dto.ts new file mode 100644 index 00000000..122a704d --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/user-tag.dto.ts @@ -0,0 +1,338 @@ +import { + IsString, + IsOptional, + IsEnum, + IsBoolean, + IsInt, + IsArray, + Min, + Max, + IsDateString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { TagType, TagValueType } from '../../../domain/entities/user-tag.entity'; + +// ===================== +// 标签分类 DTO +// ===================== + +/** + * 创建标签分类 + */ +export class CreateTagCategoryDto { + @IsString() + code: string; + + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; +} + +/** + * 更新标签分类 + */ +export class UpdateTagCategoryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} + +// ===================== +// 标签定义 DTO +// ===================== + +/** + * 创建标签 + */ +export class CreateTagDto { + @IsString() + code: string; + + @IsString() + name: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + color?: string; + + @IsOptional() + @IsEnum(TagType) + type?: TagType; + + @IsOptional() + @IsEnum(TagValueType) + valueType?: TagValueType; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + enumValues?: string[]; + + @IsOptional() + @IsBoolean() + isAdvertisable?: boolean; + + @IsOptional() + @IsInt() + sortOrder?: number; +} + +/** + * 更新标签 + */ +export class UpdateTagDto { + @IsOptional() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsString() + color?: string | null; + + @IsOptional() + @IsEnum(TagValueType) + valueType?: TagValueType; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + enumValues?: string[] | null; + + @IsOptional() + @IsBoolean() + isAdvertisable?: boolean; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsInt() + sortOrder?: number; +} + +/** + * 查询标签列表 + */ +export class ListTagsDto { + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsEnum(TagType) + type?: TagType; + + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsBoolean() + isAdvertisable?: boolean; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +// ===================== +// 标签关联规则 DTO +// ===================== + +/** + * 关联规则到标签 + */ +export class LinkRuleToTagDto { + @IsString() + ruleId: string; +} + +// ===================== +// 用户标签操作 DTO +// ===================== + +/** + * 给用户打标签 + */ +export class AssignTagToUserDto { + @IsString() + accountSequence: string; + + @IsString() + tagId: string; + + @IsOptional() + @IsString() + value?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; + + @IsOptional() + @IsString() + reason?: string; +} + +/** + * 批量打标签 + */ +export class BatchAssignTagDto { + @IsArray() + @IsString({ each: true }) + accountSequences: string[]; + + @IsString() + tagId: string; + + @IsOptional() + @IsString() + value?: string; + + @IsOptional() + @IsString() + reason?: string; +} + +/** + * 移除用户标签 + */ +export class RemoveTagFromUserDto { + @IsString() + accountSequence: string; + + @IsString() + tagId: string; + + @IsOptional() + @IsString() + reason?: string; +} + +/** + * 查询用户标签 + */ +export class GetUserTagsDto { + @IsString() + accountSequence: string; +} + +/** + * 查询标签下的用户 + */ +export class GetTagUsersDto { + @IsString() + tagId: string; + + @IsOptional() + @IsString() + value?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} + +/** + * 同步用户自动标签 + */ +export class SyncUserAutoTagsDto { + @IsString() + accountSequence: string; +} + +/** + * 批量同步自动标签 + */ +export class BatchSyncAutoTagsDto { + @IsArray() + @IsString({ each: true }) + accountSequences: string[]; +} + +// ===================== +// 标签日志查询 DTO +// ===================== + +/** + * 查询标签变更日志 + */ +export class ListTagLogsDto { + @IsOptional() + @IsString() + accountSequence?: string; + + @IsOptional() + @IsString() + tagCode?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} diff --git a/backend/services/admin-service/src/api/dto/response/audience-segment.dto.ts b/backend/services/admin-service/src/api/dto/response/audience-segment.dto.ts new file mode 100644 index 00000000..b048a258 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/audience-segment.dto.ts @@ -0,0 +1,85 @@ +import { + AudienceSegmentEntity, + SegmentConditionGroup, + SegmentUsageType, +} from '../../../domain/entities/audience-segment.entity'; + +/** + * 人群包响应 + */ +export class SegmentResponseDto { + id: string; + name: string; + description: string | null; + conditions: SegmentConditionGroup; + estimatedUsers: number | null; + lastCalculated: string | null; + usageType: SegmentUsageType; + isEnabled: boolean; + createdAt: string; + updatedAt: string; + createdBy: string; + + static fromEntity(entity: AudienceSegmentEntity): SegmentResponseDto { + return { + id: entity.id, + name: entity.name, + description: entity.description, + conditions: entity.conditions, + estimatedUsers: entity.estimatedUsers, + lastCalculated: entity.lastCalculated?.toISOString() ?? null, + usageType: entity.usageType, + isEnabled: entity.isEnabled, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + createdBy: entity.createdBy, + }; + } +} + +/** + * 人群包列表响应 + */ +export class SegmentListResponseDto { + items: SegmentResponseDto[]; + total: number; +} + +/** + * 人群包测试结果 + */ +export class SegmentTestResultDto { + segmentId: string; + segmentName: string; + accountSequence: string; + matched: boolean; +} + +/** + * 人群包预估结果 + */ +export class SegmentEstimateResultDto { + estimatedUsers: number; + sampleUsers: string[]; +} + +/** + * 人群包用户列表 + */ +export class SegmentUsersResponseDto { + segmentId: string; + users: string[]; + total: number; +} + +/** + * 用户匹配的人群包列表 + */ +export class UserSegmentsResponseDto { + accountSequence: string; + segments: Array<{ + segmentId: string; + segmentName: string; + matched: boolean; + }>; +} diff --git a/backend/services/admin-service/src/api/dto/response/classification-rule.dto.ts b/backend/services/admin-service/src/api/dto/response/classification-rule.dto.ts new file mode 100644 index 00000000..bdd49e68 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/classification-rule.dto.ts @@ -0,0 +1,69 @@ +import { + ClassificationRuleEntity, + RuleConditions, +} from '../../../domain/entities/classification-rule.entity'; + +/** + * 规则响应 + */ +export class RuleResponseDto { + id: string; + name: string; + description: string | null; + conditions: RuleConditions; + isEnabled: boolean; + linkedTagId: string | null; + linkedTagCode: string | null; + createdAt: string; + updatedAt: string; + + static fromEntity( + entity: ClassificationRuleEntity, + linkedTag?: { tagId: string; tagCode: string } | null, + ): RuleResponseDto { + return { + id: entity.id, + name: entity.name, + description: entity.description, + conditions: entity.conditions, + isEnabled: entity.isEnabled, + linkedTagId: linkedTag?.tagId ?? null, + linkedTagCode: linkedTag?.tagCode ?? null, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + }; + } +} + +/** + * 规则列表响应 + */ +export class RuleListResponseDto { + items: RuleResponseDto[]; + total: number; +} + +/** + * 规则测试结果响应 + */ +export class RuleTestResultDto { + ruleId: string; + ruleName: string; + accountSequence: string; + matched: boolean; + evaluatedConditions: Array<{ + field: string; + operator: string; + expectedValue: unknown; + actualValue: unknown; + matched: boolean; + }>; +} + +/** + * 规则预估结果响应 + */ +export class RuleEstimateResultDto { + estimatedUsers: number; + sampleUsers: string[]; +} diff --git a/backend/services/admin-service/src/api/dto/response/notification.dto.ts b/backend/services/admin-service/src/api/dto/response/notification.dto.ts index d2a62439..48e3cefe 100644 --- a/backend/services/admin-service/src/api/dto/response/notification.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/notification.dto.ts @@ -1,6 +1,22 @@ -import { NotificationEntity, NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity'; +import { + NotificationEntity, + NotificationType, + NotificationPriority, + TargetType, + NotificationTarget, +} from '../../../domain/entities/notification.entity'; import { NotificationWithReadStatus } from '../../../domain/repositories/notification.repository'; +/** + * 目标配置响应 + */ +export interface TargetConfigResponseDto { + type: TargetType; + tagIds?: string[]; + segmentId?: string; + accountSequences?: string[]; +} + /** * 通知响应DTO */ @@ -11,6 +27,7 @@ export class NotificationResponseDto { type: NotificationType; priority: NotificationPriority; targetType: TargetType; + targetConfig: TargetConfigResponseDto | null; imageUrl: string | null; linkUrl: string | null; isEnabled: boolean; @@ -26,6 +43,7 @@ export class NotificationResponseDto { type: entity.type, priority: entity.priority, targetType: entity.targetType, + targetConfig: entity.targetConfig, imageUrl: entity.imageUrl, linkUrl: entity.linkUrl, isEnabled: entity.isEnabled, diff --git a/backend/services/admin-service/src/api/dto/response/user-tag.dto.ts b/backend/services/admin-service/src/api/dto/response/user-tag.dto.ts new file mode 100644 index 00000000..fee6f56f --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/user-tag.dto.ts @@ -0,0 +1,183 @@ +import { + TagCategoryEntity, + UserTagEntity, + TagType, + TagValueType, + UserTagLogEntity, + TagAction, +} from '../../../domain/entities/user-tag.entity'; + +/** + * 标签分类响应 + */ +export class TagCategoryResponseDto { + id: string; + code: string; + name: string; + description: string | null; + sortOrder: number; + isEnabled: boolean; + createdAt: string; + updatedAt: string; + + static fromEntity(entity: TagCategoryEntity): TagCategoryResponseDto { + return { + id: entity.id, + code: entity.code, + name: entity.name, + description: entity.description, + sortOrder: entity.sortOrder, + isEnabled: entity.isEnabled, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + }; + } +} + +/** + * 标签响应 + */ +export class TagResponseDto { + id: string; + categoryId: string | null; + code: string; + name: string; + description: string | null; + color: string | null; + type: TagType; + valueType: TagValueType; + enumValues: string[] | null; + ruleId: string | null; + isAdvertisable: boolean; + estimatedUsers: number | null; + isEnabled: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; + + static fromEntity(entity: UserTagEntity): TagResponseDto { + return { + id: entity.id, + categoryId: entity.categoryId, + code: entity.code, + name: entity.name, + description: entity.description, + color: entity.color, + type: entity.type, + valueType: entity.valueType, + enumValues: entity.enumValues, + ruleId: entity.ruleId, + isAdvertisable: entity.isAdvertisable, + estimatedUsers: entity.estimatedUsers, + isEnabled: entity.isEnabled, + sortOrder: entity.sortOrder, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + }; + } +} + +/** + * 标签列表响应 + */ +export class TagListResponseDto { + items: TagResponseDto[]; + total: number; +} + +/** + * 用户标签响应 + */ +export class UserTagResponseDto { + tagId: string; + tagCode: string; + tagName: string; + value: string | null; + isAuto: boolean; + assignedAt: string; +} + +/** + * 用户标签列表响应 + */ +export class UserTagListResponseDto { + accountSequence: string; + tags: UserTagResponseDto[]; +} + +/** + * 标签用户响应 + */ +export class TagUserResponseDto { + accountSequence: string; + value: string | null; + assignedAt: string; + assignedBy: string | null; + source: string | null; +} + +/** + * 标签用户列表响应 + */ +export class TagUserListResponseDto { + tagId: string; + users: TagUserResponseDto[]; + total: number; +} + +/** + * 标签变更日志响应 + */ +export class TagLogResponseDto { + id: string; + accountSequence: string; + tagCode: string; + action: TagAction; + oldValue: string | null; + newValue: string | null; + reason: string | null; + operatorId: string | null; + createdAt: string; + + static fromEntity(entity: UserTagLogEntity): TagLogResponseDto { + return { + id: entity.id, + accountSequence: entity.accountSequence, + tagCode: entity.tagCode, + action: entity.action, + oldValue: entity.oldValue, + newValue: entity.newValue, + reason: entity.reason, + operatorId: entity.operatorId, + createdAt: entity.createdAt.toISOString(), + }; + } +} + +/** + * 标签同步结果响应 + */ +export class TagSyncResultResponseDto { + accountSequence: string; + added: string[]; + removed: string[]; + unchanged: string[]; +} + +/** + * 批量同步结果响应 + */ +export class BatchSyncResultResponseDto { + total: number; + processed: number; + results: TagSyncResultResponseDto[]; + errors: Array<{ accountSequence: string; error: string }>; +} + +/** + * 批量操作结果响应 + */ +export class BatchOperationResultDto { + success: number; + failed: number; +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 50c8a984..4f73273a 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { ScheduleModule } from '@nestjs/schedule'; import { join } from 'path'; import { configurations } from './config'; import { PrismaService } from './infrastructure/persistence/prisma/prisma.service'; @@ -35,6 +36,20 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons import { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl'; import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository'; import { AdminSystemConfigController, PublicSystemConfigController } from './api/controllers/system-config.controller'; +// User Profile System imports (标签、规则、人群包) +import { USER_TAG_REPOSITORY } from './domain/repositories/user-tag.repository'; +import { UserTagRepositoryImpl } from './infrastructure/persistence/repositories/user-tag.repository.impl'; +import { CLASSIFICATION_RULE_REPOSITORY } from './domain/repositories/classification-rule.repository'; +import { ClassificationRuleRepositoryImpl } from './infrastructure/persistence/repositories/classification-rule.repository.impl'; +import { AUDIENCE_SEGMENT_REPOSITORY } from './domain/repositories/audience-segment.repository'; +import { AudienceSegmentRepositoryImpl } from './infrastructure/persistence/repositories/audience-segment.repository.impl'; +import { RuleEngineService } from './application/services/rule-engine.service'; +import { UserTaggingService } from './application/services/user-tagging.service'; +import { AudienceSegmentService } from './application/services/audience-segment.service'; +import { UserTagController } from './api/controllers/user-tag.controller'; +import { ClassificationRuleController } from './api/controllers/classification-rule.controller'; +import { AudienceSegmentController } from './api/controllers/audience-segment.controller'; +import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job'; @Module({ imports: [ @@ -47,6 +62,8 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api rootPath: join(process.cwd(), process.env.UPLOAD_DIR || 'uploads'), serveRoot: '/uploads', }), + // Schedule module for cron jobs + ScheduleModule.forRoot(), ], controllers: [ VersionController, @@ -58,6 +75,10 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api UserController, AdminSystemConfigController, PublicSystemConfigController, + // User Profile System Controllers + UserTagController, + ClassificationRuleController, + AudienceSegmentController, ], providers: [ PrismaService, @@ -95,6 +116,24 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api provide: SYSTEM_CONFIG_REPOSITORY, useClass: SystemConfigRepositoryImpl, }, + // User Profile System (标签、规则、人群包) + { + provide: USER_TAG_REPOSITORY, + useClass: UserTagRepositoryImpl, + }, + { + provide: CLASSIFICATION_RULE_REPOSITORY, + useClass: ClassificationRuleRepositoryImpl, + }, + { + provide: AUDIENCE_SEGMENT_REPOSITORY, + useClass: AudienceSegmentRepositoryImpl, + }, + RuleEngineService, + UserTaggingService, + AudienceSegmentService, + // Scheduled Jobs + AutoTagSyncJob, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/application/services/audience-segment.service.ts b/backend/services/admin-service/src/application/services/audience-segment.service.ts new file mode 100644 index 00000000..6942eda7 --- /dev/null +++ b/backend/services/admin-service/src/application/services/audience-segment.service.ts @@ -0,0 +1,484 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { + AUDIENCE_SEGMENT_REPOSITORY, + AudienceSegmentRepository, +} from '../../domain/repositories/audience-segment.repository'; +import { + USER_TAG_REPOSITORY, + UserTagRepository, +} from '../../domain/repositories/user-tag.repository'; +import { + USER_QUERY_REPOSITORY, + IUserQueryRepository, + UserQueryItem, +} from '../../domain/repositories/user-query.repository'; +import { + AudienceSegmentEntity, + SegmentConditionGroup, + SegmentCondition, + SegmentUsageType, +} from '../../domain/entities/audience-segment.entity'; + +/** + * 人群包匹配结果 + */ +export interface SegmentMatchResult { + segmentId: string; + segmentName: string; + matched: boolean; +} + +/** + * 人群包服务 + * 负责人群包的创建、评估和用户匹配 + */ +@Injectable() +export class AudienceSegmentService { + private readonly logger = new Logger(AudienceSegmentService.name); + + constructor( + @Inject(AUDIENCE_SEGMENT_REPOSITORY) + private readonly segmentRepository: AudienceSegmentRepository, + @Inject(USER_TAG_REPOSITORY) + private readonly tagRepository: UserTagRepository, + @Inject(USER_QUERY_REPOSITORY) + private readonly userQueryRepository: IUserQueryRepository, + ) {} + + /** + * 创建人群包 + */ + async createSegment(params: { + name: string; + description?: string; + conditions: SegmentConditionGroup; + usageType?: SegmentUsageType; + createdBy: string; + }): Promise { + // 验证条件格式 + if (!AudienceSegmentEntity.validateConditions(params.conditions)) { + throw new Error('Invalid segment conditions format'); + } + + const segment = AudienceSegmentEntity.create({ + id: uuidv4(), + name: params.name, + description: params.description, + conditions: params.conditions, + usageType: params.usageType, + createdBy: params.createdBy, + }); + + const saved = await this.segmentRepository.save(segment); + + // 异步计算预估用户数 + this.calculateEstimatedUsers(saved.id).catch((err) => { + this.logger.error(`Failed to calculate estimated users: ${err.message}`); + }); + + return saved; + } + + /** + * 更新人群包 + */ + async updateSegment( + id: string, + params: { + name?: string; + description?: string | null; + conditions?: SegmentConditionGroup; + usageType?: SegmentUsageType; + isEnabled?: boolean; + }, + ): Promise { + const segment = await this.segmentRepository.findById(id); + if (!segment) { + throw new Error(`Segment not found: ${id}`); + } + + if (params.conditions) { + if (!AudienceSegmentEntity.validateConditions(params.conditions)) { + throw new Error('Invalid segment conditions format'); + } + } + + const updated = segment.update(params); + const saved = await this.segmentRepository.save(updated); + + // 如果条件变化,重新计算预估用户数 + if (params.conditions) { + this.calculateEstimatedUsers(saved.id).catch((err) => { + this.logger.error(`Failed to calculate estimated users: ${err.message}`); + }); + } + + return saved; + } + + /** + * 删除人群包 + */ + async deleteSegment(id: string): Promise { + await this.segmentRepository.delete(id); + } + + /** + * 获取人群包详情 + */ + async getSegment(id: string): Promise { + return this.segmentRepository.findById(id); + } + + /** + * 获取人群包列表 + */ + async listSegments(params?: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise<{ items: AudienceSegmentEntity[]; total: number }> { + const [items, total] = await Promise.all([ + this.segmentRepository.findAll(params), + this.segmentRepository.count({ + usageType: params?.usageType, + isEnabled: params?.isEnabled, + }), + ]); + + return { items, total }; + } + + /** + * 评估用户是否匹配人群包 + */ + async evaluateUser( + accountSequence: string, + segmentId: string, + ): Promise { + const segment = await this.segmentRepository.findById(segmentId); + if (!segment || !segment.isEnabled) { + return false; + } + + const user = + await this.userQueryRepository.findByAccountSequence(accountSequence); + if (!user) { + return false; + } + + const userTagIds = await this.tagRepository.findUserTagIds(accountSequence); + + return this.evaluateConditions(user, userTagIds, segment.conditions); + } + + /** + * 评估用户匹配的所有人群包 + */ + async evaluateUserSegments( + accountSequence: string, + usageType?: SegmentUsageType, + ): Promise { + const segments = await this.segmentRepository.findAll({ + usageType, + isEnabled: true, + }); + + const user = + await this.userQueryRepository.findByAccountSequence(accountSequence); + if (!user) { + return segments.map((s) => ({ + segmentId: s.id, + segmentName: s.name, + matched: false, + })); + } + + const userTagIds = await this.tagRepository.findUserTagIds(accountSequence); + + return segments.map((segment) => ({ + segmentId: segment.id, + segmentName: segment.name, + matched: this.evaluateConditions(user, userTagIds, segment.conditions), + })); + } + + /** + * 查找匹配人群包的所有用户 + */ + async findMatchingUsers( + segmentId: string, + options?: { limit?: number; offset?: number }, + ): Promise<{ accountSequences: string[]; total: number }> { + const segment = await this.segmentRepository.findById(segmentId); + if (!segment) { + return { accountSequences: [], total: 0 }; + } + + // 获取所有用户并评估 + // TODO: 优化为数据库层面的筛选 + const allUsers = await this.userQueryRepository.findMany( + {}, + { page: 1, pageSize: 10000 }, + ); + + const matchingSequences: string[] = []; + + for (const user of allUsers.items) { + const userTagIds = await this.tagRepository.findUserTagIds( + user.accountSequence, + ); + if (this.evaluateConditions(user, userTagIds, segment.conditions)) { + matchingSequences.push(user.accountSequence); + } + } + + const total = matchingSequences.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? total; + + return { + accountSequences: matchingSequences.slice(offset, offset + limit), + total, + }; + } + + /** + * 计算人群包预估用户数 + */ + async calculateEstimatedUsers(segmentId: string): Promise { + const { total } = await this.findMatchingUsers(segmentId); + await this.segmentRepository.updateEstimatedUsers(segmentId, total); + return total; + } + + /** + * 批量更新所有人群包的预估用户数 + */ + async recalculateAllEstimatedUsers(): Promise { + const segments = await this.segmentRepository.findAll({ isEnabled: true }); + + for (const segment of segments) { + try { + await this.calculateEstimatedUsers(segment.id); + } catch (err) { + this.logger.error( + `Failed to calculate estimated users for segment ${segment.id}: ${err}`, + ); + } + } + } + + /** + * 评估条件组 + */ + private evaluateConditions( + user: UserQueryItem, + userTagIds: string[], + group: SegmentConditionGroup, + ): boolean { + if (group.type === 'AND') { + return group.conditions.every((condition) => { + if (this.isConditionGroup(condition)) { + return this.evaluateConditions( + user, + userTagIds, + condition as SegmentConditionGroup, + ); + } + return this.evaluateSingleCondition( + user, + userTagIds, + condition as SegmentCondition, + ); + }); + } else { + return group.conditions.some((condition) => { + if (this.isConditionGroup(condition)) { + return this.evaluateConditions( + user, + userTagIds, + condition as SegmentConditionGroup, + ); + } + return this.evaluateSingleCondition( + user, + userTagIds, + condition as SegmentCondition, + ); + }); + } + } + + /** + * 判断是否为条件组 + */ + private isConditionGroup( + condition: SegmentCondition | SegmentConditionGroup, + ): boolean { + return ( + 'type' in condition && + ['AND', 'OR'].includes(condition.type as string) && + 'conditions' in condition + ); + } + + /** + * 评估单个条件 + */ + private evaluateSingleCondition( + user: UserQueryItem, + userTagIds: string[], + condition: SegmentCondition, + ): boolean { + switch (condition.type) { + case 'tag': + return this.evaluateTagCondition(userTagIds, condition); + case 'feature': + return this.evaluateFeatureCondition(user, condition); + case 'profile': + return this.evaluateProfileCondition(user, condition); + default: + return false; + } + } + + /** + * 评估标签条件 + */ + private evaluateTagCondition( + userTagIds: string[], + condition: SegmentCondition, + ): boolean { + const tagCode = condition.field; + + // TODO: 需要根据 tagCode 查找对应的 tagId + // 目前简化处理,假设 field 就是 tagId + const hasTag = userTagIds.includes(tagCode); + + switch (condition.operator) { + case 'eq': + return hasTag === (condition.value === true || condition.value === 'true'); + case 'neq': + return hasTag !== (condition.value === true || condition.value === 'true'); + case 'in': + if (Array.isArray(condition.value)) { + return condition.value.some((v) => userTagIds.includes(v as string)); + } + return false; + case 'not_in': + if (Array.isArray(condition.value)) { + return !condition.value.some((v) => userTagIds.includes(v as string)); + } + return true; + default: + return hasTag; + } + } + + /** + * 评估特征条件 (RFM等) + */ + private evaluateFeatureCondition( + _user: UserQueryItem, + _condition: SegmentCondition, + ): boolean { + // TODO: 需要从 UserFeature 表获取特征数据 + // 目前返回 false,待实现 + return false; + } + + /** + * 评估用户资料条件 + */ + private evaluateProfileCondition( + user: UserQueryItem, + condition: SegmentCondition, + ): boolean { + const value = this.getProfileValue(user, condition.field); + return this.compareValues(value, condition.operator, condition.value); + } + + /** + * 获取用户资料字段值 + */ + private getProfileValue( + user: UserQueryItem, + field: string, + ): number | string | Date | null { + switch (field) { + case 'registeredAt': + return user.registeredAt; + case 'personalAdoptionCount': + return user.personalAdoptionCount; + case 'teamAdoptionCount': + return user.teamAdoptionCount; + case 'teamAddressCount': + return user.teamAddressCount; + case 'provinceAdoptionCount': + return user.provinceAdoptionCount; + case 'cityAdoptionCount': + return user.cityAdoptionCount; + case 'kycStatus': + return user.kycStatus; + case 'lastActiveAt': + return user.lastActiveAt; + case 'status': + return user.status; + default: + return null; + } + } + + /** + * 比较值 + */ + private compareValues( + actual: number | string | Date | null, + operator: string, + expected: unknown, + ): boolean { + if (actual === null) return false; + + switch (operator) { + case 'eq': + return actual === expected; + case 'neq': + return actual !== expected; + case 'gt': + return typeof actual === 'number' && actual > Number(expected); + case 'gte': + return typeof actual === 'number' && actual >= Number(expected); + case 'lt': + return typeof actual === 'number' && actual < Number(expected); + case 'lte': + return typeof actual === 'number' && actual <= Number(expected); + case 'in': + return Array.isArray(expected) && expected.includes(String(actual)); + case 'not_in': + return Array.isArray(expected) && !expected.includes(String(actual)); + case 'contains': + return ( + typeof actual === 'string' && + typeof expected === 'string' && + actual.includes(expected) + ); + case 'starts_with': + return ( + typeof actual === 'string' && + typeof expected === 'string' && + actual.startsWith(expected) + ); + case 'ends_with': + return ( + typeof actual === 'string' && + typeof expected === 'string' && + actual.endsWith(expected) + ); + default: + return false; + } + } +} diff --git a/backend/services/admin-service/src/application/services/rule-engine.service.ts b/backend/services/admin-service/src/application/services/rule-engine.service.ts new file mode 100644 index 00000000..e77e4798 --- /dev/null +++ b/backend/services/admin-service/src/application/services/rule-engine.service.ts @@ -0,0 +1,317 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { + ClassificationRuleEntity, + RuleConditions, + FieldCondition, + GroupCondition, +} from '../../domain/entities/classification-rule.entity'; +import { + CLASSIFICATION_RULE_REPOSITORY, + ClassificationRuleRepository, +} from '../../domain/repositories/classification-rule.repository'; +import { + USER_QUERY_REPOSITORY, + IUserQueryRepository, + UserQueryItem, +} from '../../domain/repositories/user-query.repository'; + +/** + * 规则评估结果 + */ +export interface RuleEvaluationResult { + ruleId: string; + ruleName: string; + matched: boolean; + tagId?: string; + tagCode?: string; +} + +/** + * 用户规则评估结果 + */ +export interface UserRuleEvaluationResult { + accountSequence: string; + matchedRules: RuleEvaluationResult[]; + matchedTagIds: string[]; +} + +/** + * 规则引擎服务 + * 负责评估用户分类规则 + */ +@Injectable() +export class RuleEngineService { + constructor( + @Inject(CLASSIFICATION_RULE_REPOSITORY) + private readonly ruleRepository: ClassificationRuleRepository, + @Inject(USER_QUERY_REPOSITORY) + private readonly userQueryRepository: IUserQueryRepository, + ) {} + + /** + * 评估单个用户是否匹配规则 + */ + evaluateUser(user: UserQueryItem, rule: ClassificationRuleEntity): boolean { + return this.evaluateConditions(user, rule.conditions); + } + + /** + * 评估用户匹配的所有规则 + */ + async evaluateUserRules( + accountSequence: string, + ): Promise { + const user = + await this.userQueryRepository.findByAccountSequence(accountSequence); + if (!user) { + return { + accountSequence, + matchedRules: [], + matchedTagIds: [], + }; + } + + const linkedRules = await this.ruleRepository.findLinkedRules(); + const matchedRules: RuleEvaluationResult[] = []; + const matchedTagIds: string[] = []; + + for (const { rule, tagId, tagCode } of linkedRules) { + if (!rule.isEnabled) continue; + + const matched = this.evaluateUser(user, rule); + matchedRules.push({ + ruleId: rule.id, + ruleName: rule.name, + matched, + tagId, + tagCode, + }); + + if (matched) { + matchedTagIds.push(tagId); + } + } + + return { + accountSequence, + matchedRules, + matchedTagIds, + }; + } + + /** + * 批量评估用户规则 + */ + async evaluateUsersRules( + accountSequences: string[], + ): Promise> { + const results = new Map(); + + // 获取所有已关联标签的启用规则 + const linkedRules = await this.ruleRepository.findLinkedRules(); + const enabledRules = linkedRules.filter((r) => r.rule.isEnabled); + + for (const accountSequence of accountSequences) { + const user = + await this.userQueryRepository.findByAccountSequence(accountSequence); + if (!user) { + results.set(accountSequence, { + accountSequence, + matchedRules: [], + matchedTagIds: [], + }); + continue; + } + + const matchedRules: RuleEvaluationResult[] = []; + const matchedTagIds: string[] = []; + + for (const { rule, tagId, tagCode } of enabledRules) { + const matched = this.evaluateUser(user, rule); + matchedRules.push({ + ruleId: rule.id, + ruleName: rule.name, + matched, + tagId, + tagCode, + }); + + if (matched) { + matchedTagIds.push(tagId); + } + } + + results.set(accountSequence, { + accountSequence, + matchedRules, + matchedTagIds, + }); + } + + return results; + } + + /** + * 查找匹配规则的所有用户 + * 返回 accountSequence 列表 + */ + async findMatchingUsers( + rule: ClassificationRuleEntity, + options?: { limit?: number; offset?: number }, + ): Promise<{ accountSequences: string[]; total: number }> { + // 获取所有用户并评估 + // TODO: 优化为数据库层面的筛选 + const result = await this.userQueryRepository.findMany( + {}, + { page: 1, pageSize: 10000 }, + ); + + const matchingSequences: string[] = []; + for (const user of result.items) { + if (this.evaluateUser(user, rule)) { + matchingSequences.push(user.accountSequence); + } + } + + const total = matchingSequences.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? total; + + return { + accountSequences: matchingSequences.slice(offset, offset + limit), + total, + }; + } + + /** + * 预估规则匹配用户数 + */ + async estimateMatchingUsers(rule: ClassificationRuleEntity): Promise { + const { total } = await this.findMatchingUsers(rule); + return total; + } + + /** + * 评估条件组 + */ + private evaluateConditions( + user: UserQueryItem, + conditions: RuleConditions, + ): boolean { + return this.evaluateGroup(user, conditions); + } + + /** + * 评估条件组 (AND/OR) + */ + private evaluateGroup(user: UserQueryItem, group: GroupCondition): boolean { + if (group.type === 'AND') { + return group.rules.every((rule) => { + if ('type' in rule && ['AND', 'OR'].includes(rule.type as string)) { + return this.evaluateGroup(user, rule as GroupCondition); + } + return this.evaluateFieldCondition(user, rule as FieldCondition); + }); + } else { + return group.rules.some((rule) => { + if ('type' in rule && ['AND', 'OR'].includes(rule.type as string)) { + return this.evaluateGroup(user, rule as GroupCondition); + } + return this.evaluateFieldCondition(user, rule as FieldCondition); + }); + } + } + + /** + * 评估字段条件 + */ + private evaluateFieldCondition( + user: UserQueryItem, + condition: FieldCondition, + ): boolean { + const value = this.getFieldValue(user, condition.field); + const compareValue = condition.value; + + switch (condition.operator) { + case 'eq': + return value === compareValue; + + case 'neq': + return value !== compareValue; + + case 'gt': + return typeof value === 'number' && value > Number(compareValue); + + case 'gte': + return typeof value === 'number' && value >= Number(compareValue); + + case 'lt': + return typeof value === 'number' && value < Number(compareValue); + + case 'lte': + return typeof value === 'number' && value <= Number(compareValue); + + case 'within_days': { + // 在 N 天内 + if (!(value instanceof Date)) return false; + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - Number(compareValue)); + return value >= daysAgo; + } + + case 'before_days': { + // N 天前 + if (!(value instanceof Date)) return false; + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - Number(compareValue)); + return value < daysAgo; + } + + case 'in': + if (Array.isArray(compareValue)) { + return compareValue.includes(String(value)); + } + return false; + + case 'not_in': + if (Array.isArray(compareValue)) { + return !compareValue.includes(String(value)); + } + return true; + + default: + return false; + } + } + + /** + * 获取用户字段值 + */ + private getFieldValue( + user: UserQueryItem, + field: string, + ): number | string | Date | null { + switch (field) { + case 'registeredAt': + return user.registeredAt; + case 'personalAdoptionCount': + return user.personalAdoptionCount; + case 'teamAdoptionCount': + return user.teamAdoptionCount; + case 'teamAddressCount': + return user.teamAddressCount; + case 'provinceAdoptionCount': + return user.provinceAdoptionCount; + case 'cityAdoptionCount': + return user.cityAdoptionCount; + case 'kycStatus': + return user.kycStatus; + case 'lastActiveAt': + return user.lastActiveAt; + case 'status': + return user.status; + default: + return null; + } + } +} diff --git a/backend/services/admin-service/src/application/services/user-tagging.service.ts b/backend/services/admin-service/src/application/services/user-tagging.service.ts new file mode 100644 index 00000000..4d8ab392 --- /dev/null +++ b/backend/services/admin-service/src/application/services/user-tagging.service.ts @@ -0,0 +1,427 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { RuleEngineService } from './rule-engine.service'; +import { + USER_TAG_REPOSITORY, + UserTagRepository, +} from '../../domain/repositories/user-tag.repository'; +import { + USER_QUERY_REPOSITORY, + IUserQueryRepository, +} from '../../domain/repositories/user-query.repository'; +import { + UserTagAssignmentEntity, + UserTagLogEntity, + TagAction, +} from '../../domain/entities/user-tag.entity'; + +/** + * 标签同步结果 + */ +export interface TagSyncResult { + accountSequence: string; + added: string[]; // 新增的 tagId + removed: string[]; // 移除的 tagId + unchanged: string[]; // 未变化的 tagId +} + +/** + * 批量同步结果 + */ +export interface BatchSyncResult { + total: number; + processed: number; + results: TagSyncResult[]; + errors: Array<{ accountSequence: string; error: string }>; +} + +/** + * 用户标签服务 + * 负责用户标签的打标、移除、同步等操作 + */ +@Injectable() +export class UserTaggingService { + private readonly logger = new Logger(UserTaggingService.name); + + constructor( + private readonly ruleEngine: RuleEngineService, + @Inject(USER_TAG_REPOSITORY) + private readonly tagRepository: UserTagRepository, + @Inject(USER_QUERY_REPOSITORY) + private readonly userQueryRepository: IUserQueryRepository, + ) {} + + /** + * 手动给用户打标签 + */ + async assignTag(params: { + accountSequence: string; + tagId: string; + value?: string | null; + assignedBy: string; + expiresAt?: Date | null; + reason?: string; + }): Promise { + const tag = await this.tagRepository.findTagById(params.tagId); + if (!tag) { + throw new Error(`Tag not found: ${params.tagId}`); + } + + // 检查是否已有关联 + const existing = await this.tagRepository.getAssignment( + params.accountSequence, + params.tagId, + ); + + if (existing) { + // 更新现有关联的值 + if (params.value !== undefined) { + const updated = existing.updateValue(params.value); + await this.tagRepository.saveAssignment(updated); + + // 记录日志 + await this.tagRepository.saveLog( + UserTagLogEntity.create({ + id: uuidv4(), + accountSequence: params.accountSequence, + tagCode: tag.code, + action: TagAction.UPDATE, + oldValue: existing.value, + newValue: params.value, + reason: params.reason, + operatorId: params.assignedBy, + }), + ); + } + return; + } + + // 创建新关联 + const assignment = UserTagAssignmentEntity.createManual({ + id: uuidv4(), + accountSequence: params.accountSequence, + tagId: params.tagId, + value: params.value, + assignedBy: params.assignedBy, + expiresAt: params.expiresAt, + }); + + await this.tagRepository.saveAssignment(assignment); + + // 记录日志 + await this.tagRepository.saveLog( + UserTagLogEntity.create({ + id: uuidv4(), + accountSequence: params.accountSequence, + tagCode: tag.code, + action: TagAction.ASSIGN, + newValue: params.value, + reason: params.reason, + operatorId: params.assignedBy, + }), + ); + } + + /** + * 移除用户标签 + */ + async removeTag(params: { + accountSequence: string; + tagId: string; + operatorId: string; + reason?: string; + }): Promise { + const tag = await this.tagRepository.findTagById(params.tagId); + if (!tag) { + throw new Error(`Tag not found: ${params.tagId}`); + } + + const existing = await this.tagRepository.getAssignment( + params.accountSequence, + params.tagId, + ); + if (!existing) { + return; // 没有关联,无需移除 + } + + await this.tagRepository.deleteAssignment( + params.accountSequence, + params.tagId, + ); + + // 记录日志 + await this.tagRepository.saveLog( + UserTagLogEntity.create({ + id: uuidv4(), + accountSequence: params.accountSequence, + tagCode: tag.code, + action: TagAction.REMOVE, + oldValue: existing.value, + reason: params.reason, + operatorId: params.operatorId, + }), + ); + } + + /** + * 批量给用户打标签 + */ + async batchAssignTag(params: { + accountSequences: string[]; + tagId: string; + value?: string | null; + assignedBy: string; + reason?: string; + }): Promise<{ success: number; failed: number }> { + let success = 0; + let failed = 0; + + for (const accountSequence of params.accountSequences) { + try { + await this.assignTag({ + accountSequence, + tagId: params.tagId, + value: params.value, + assignedBy: params.assignedBy, + reason: params.reason, + }); + success++; + } catch { + failed++; + } + } + + return { success, failed }; + } + + /** + * 同步单个用户的自动标签 + * 根据规则引擎评估结果,自动添加/移除标签 + */ + async syncUserAutoTags(accountSequence: string): Promise { + const result: TagSyncResult = { + accountSequence, + added: [], + removed: [], + unchanged: [], + }; + + // 获取用户当前的自动标签 + const currentTags = await this.tagRepository.findUserTags(accountSequence); + const currentAutoTagIds = new Set( + currentTags + .filter((t) => t.assignment.isAuto()) + .map((t) => t.tag.id), + ); + + // 评估规则,获取应该有的标签 + const evaluation = await this.ruleEngine.evaluateUserRules(accountSequence); + const shouldHaveTagIds = new Set(evaluation.matchedTagIds); + + // 计算需要添加和移除的标签 + const toAdd = [...shouldHaveTagIds].filter((id) => !currentAutoTagIds.has(id)); + const toRemove = [...currentAutoTagIds].filter((id) => !shouldHaveTagIds.has(id)); + const unchanged = [...shouldHaveTagIds].filter((id) => currentAutoTagIds.has(id)); + + // 添加新标签 + for (const tagId of toAdd) { + const tag = await this.tagRepository.findTagById(tagId); + if (!tag) continue; + + const assignment = UserTagAssignmentEntity.createAuto({ + id: uuidv4(), + accountSequence, + tagId, + ruleId: tag.ruleId ?? undefined, + }); + await this.tagRepository.saveAssignment(assignment); + + // 记录日志 + await this.tagRepository.saveLog( + UserTagLogEntity.create({ + id: uuidv4(), + accountSequence, + tagCode: tag.code, + action: TagAction.ASSIGN, + reason: '自动规则匹配', + }), + ); + + result.added.push(tagId); + } + + // 移除不再匹配的标签 + for (const tagId of toRemove) { + const tag = await this.tagRepository.findTagById(tagId); + if (!tag) continue; + + await this.tagRepository.deleteAssignment(accountSequence, tagId); + + // 记录日志 + await this.tagRepository.saveLog( + UserTagLogEntity.create({ + id: uuidv4(), + accountSequence, + tagCode: tag.code, + action: TagAction.REMOVE, + reason: '自动规则不再匹配', + }), + ); + + result.removed.push(tagId); + } + + result.unchanged = unchanged; + return result; + } + + /** + * 批量同步用户自动标签 + */ + async batchSyncAutoTags( + accountSequences: string[], + ): Promise { + const result: BatchSyncResult = { + total: accountSequences.length, + processed: 0, + results: [], + errors: [], + }; + + for (const accountSequence of accountSequences) { + try { + const syncResult = await this.syncUserAutoTags(accountSequence); + result.results.push(syncResult); + result.processed++; + } catch (error) { + result.errors.push({ + accountSequence, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; + } + + /** + * 同步所有用户的自动标签 + * 用于定时任务 + */ + async syncAllAutoTags(options?: { + batchSize?: number; + onProgress?: (processed: number, total: number) => void; + }): Promise { + const batchSize = options?.batchSize ?? 100; + + // 获取所有用户 + const totalCount = await this.userQueryRepository.count(); + const result: BatchSyncResult = { + total: totalCount, + processed: 0, + results: [], + errors: [], + }; + + let page = 1; + let hasMore = true; + + while (hasMore) { + const users = await this.userQueryRepository.findMany( + {}, + { page, pageSize: batchSize }, + ); + + if (users.items.length === 0) { + hasMore = false; + break; + } + + for (const user of users.items) { + try { + const syncResult = await this.syncUserAutoTags(user.accountSequence); + result.results.push(syncResult); + result.processed++; + } catch (error) { + result.errors.push({ + accountSequence: user.accountSequence, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (options?.onProgress) { + options.onProgress(result.processed, totalCount); + } + } + + page++; + hasMore = users.items.length === batchSize; + } + + this.logger.log( + `Auto tags sync completed: ${result.processed}/${result.total}, errors: ${result.errors.length}`, + ); + + return result; + } + + /** + * 清理过期标签 + */ + async cleanExpiredTags(): Promise { + const count = await this.tagRepository.cleanExpiredAssignments(); + this.logger.log(`Cleaned ${count} expired tag assignments`); + return count; + } + + /** + * 获取用户的所有标签 + */ + async getUserTags(accountSequence: string): Promise< + Array<{ + tagId: string; + tagCode: string; + tagName: string; + value: string | null; + isAuto: boolean; + assignedAt: Date; + }> + > { + const tags = await this.tagRepository.findUserTags(accountSequence); + return tags.map((t) => ({ + tagId: t.tag.id, + tagCode: t.tag.code, + tagName: t.tag.name, + value: t.assignment.value, + isAuto: t.assignment.isAuto(), + assignedAt: t.assignment.assignedAt, + })); + } + + /** + * 检查用户是否有指定标签 + */ + async userHasTag(accountSequence: string, tagId: string): Promise { + return this.tagRepository.hasTag(accountSequence, tagId); + } + + /** + * 检查用户是否有任一标签 + */ + async userHasAnyTag( + accountSequence: string, + tagIds: string[], + ): Promise { + return this.tagRepository.hasAnyTag(accountSequence, tagIds); + } + + /** + * 检查用户是否有所有标签 + */ + async userHasAllTags( + accountSequence: string, + tagIds: string[], + ): Promise { + return this.tagRepository.hasAllTags(accountSequence, tagIds); + } +} diff --git a/backend/services/admin-service/src/domain/entities/audience-segment.entity.ts b/backend/services/admin-service/src/domain/entities/audience-segment.entity.ts new file mode 100644 index 00000000..b292d703 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/audience-segment.entity.ts @@ -0,0 +1,222 @@ +/** + * 人群包用途类型 + */ +export enum SegmentUsageType { + GENERAL = 'GENERAL', // 通用 + NOTIFICATION = 'NOTIFICATION', // 通知定向 + ADVERTISING = 'ADVERTISING', // 广告定向 + ANALYTICS = 'ANALYTICS', // 数据分析 +} + +/** + * 条件类型 + */ +export type ConditionType = 'tag' | 'feature' | 'profile'; + +/** + * 条件操作符 + */ +export type ConditionOperator = + | 'eq' // 等于 + | 'neq' // 不等于 + | 'gt' // 大于 + | 'gte' // 大于等于 + | 'lt' // 小于 + | 'lte' // 小于等于 + | 'in' // 在列表中 + | 'not_in' // 不在列表中 + | 'contains' // 包含 + | 'starts_with' // 以...开头 + | 'ends_with'; // 以...结尾 + +/** + * 单个条件 + */ +export interface SegmentCondition { + type: ConditionType; + field: string; // tagCode, rfmScore, province 等 + operator: ConditionOperator; + value: unknown; +} + +/** + * 条件组 + */ +export interface SegmentConditionGroup { + type: 'AND' | 'OR'; + conditions: (SegmentCondition | SegmentConditionGroup)[]; +} + +/** + * 人群包实体 + */ +export class AudienceSegmentEntity { + constructor( + public readonly id: string, + public readonly name: string, + public readonly description: string | null, + public readonly conditions: SegmentConditionGroup, + public readonly estimatedUsers: number | null, + public readonly lastCalculated: Date | null, + public readonly usageType: SegmentUsageType, + public readonly isEnabled: boolean, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly createdBy: string, + ) {} + + /** + * 创建人群包 + */ + static create(params: { + id: string; + name: string; + description?: string | null; + conditions: SegmentConditionGroup; + usageType?: SegmentUsageType; + createdBy: string; + }): AudienceSegmentEntity { + const now = new Date(); + return new AudienceSegmentEntity( + params.id, + params.name, + params.description ?? null, + params.conditions, + null, + null, + params.usageType ?? SegmentUsageType.GENERAL, + true, + now, + now, + params.createdBy, + ); + } + + /** + * 更新人群包 + */ + update(params: { + name?: string; + description?: string | null; + conditions?: SegmentConditionGroup; + usageType?: SegmentUsageType; + isEnabled?: boolean; + }): AudienceSegmentEntity { + return new AudienceSegmentEntity( + this.id, + params.name ?? this.name, + params.description !== undefined ? params.description : this.description, + params.conditions ?? this.conditions, + this.estimatedUsers, + this.lastCalculated, + params.usageType ?? this.usageType, + params.isEnabled ?? this.isEnabled, + this.createdAt, + new Date(), + this.createdBy, + ); + } + + /** + * 更新预估用户数 + */ + updateEstimatedUsers(count: number): AudienceSegmentEntity { + return new AudienceSegmentEntity( + this.id, + this.name, + this.description, + this.conditions, + count, + new Date(), + this.usageType, + this.isEnabled, + this.createdAt, + new Date(), + this.createdBy, + ); + } + + /** + * 验证条件格式 + */ + static validateConditions(conditions: unknown): conditions is SegmentConditionGroup { + if (!conditions || typeof conditions !== 'object') { + return false; + } + + const group = conditions as SegmentConditionGroup; + if (!['AND', 'OR'].includes(group.type)) { + return false; + } + + if (!Array.isArray(group.conditions) || group.conditions.length === 0) { + return false; + } + + return group.conditions.every((condition) => { + if ('type' in condition && ['AND', 'OR'].includes(condition.type as string)) { + return AudienceSegmentEntity.validateConditions(condition); + } + + const c = condition as SegmentCondition; + if (!['tag', 'feature', 'profile'].includes(c.type)) { + return false; + } + if (!c.field || typeof c.field !== 'string') { + return false; + } + if (!c.operator) { + return false; + } + return true; + }); + } + + /** + * 获取条件中涉及的所有标签 code + */ + getTagCodes(): string[] { + return this.extractTagCodes(this.conditions); + } + + private extractTagCodes(group: SegmentConditionGroup): string[] { + const codes: string[] = []; + + for (const condition of group.conditions) { + if ('type' in condition && ['AND', 'OR'].includes(condition.type as string)) { + codes.push(...this.extractTagCodes(condition as SegmentConditionGroup)); + } else { + const c = condition as SegmentCondition; + if (c.type === 'tag') { + codes.push(c.field); + } + } + } + + return [...new Set(codes)]; + } + + /** + * 获取条件中涉及的所有特征字段 + */ + getFeatureFields(): string[] { + return this.extractFeatureFields(this.conditions); + } + + private extractFeatureFields(group: SegmentConditionGroup): string[] { + const fields: string[] = []; + + for (const condition of group.conditions) { + if ('type' in condition && ['AND', 'OR'].includes(condition.type as string)) { + fields.push(...this.extractFeatureFields(condition as SegmentConditionGroup)); + } else { + const c = condition as SegmentCondition; + if (c.type === 'feature') { + fields.push(c.field); + } + } + } + + return [...new Set(fields)]; + } +} diff --git a/backend/services/admin-service/src/domain/entities/classification-rule.entity.ts b/backend/services/admin-service/src/domain/entities/classification-rule.entity.ts new file mode 100644 index 00000000..f8ced2e9 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/classification-rule.entity.ts @@ -0,0 +1,197 @@ +/** + * 规则条件操作符 + */ +export type RuleOperator = + | 'eq' // 等于 + | 'neq' // 不等于 + | 'gt' // 大于 + | 'gte' // 大于等于 + | 'lt' // 小于 + | 'lte' // 小于等于 + | 'within_days' // N天内 + | 'before_days' // N天前 + | 'in' // 在列表中 + | 'not_in'; // 不在列表中 + +/** + * 可用于规则的字段 + */ +export type RuleField = + | 'registeredAt' // 注册时间 + | 'personalAdoptionCount' // 个人领养数 + | 'teamAdoptionCount' // 团队领养数 + | 'teamAddressCount' // 团队地址数 + | 'provinceAdoptionCount' // 省级授权领养数 + | 'cityAdoptionCount' // 市级授权领养数 + | 'kycStatus' // KYC状态 + | 'lastActiveAt' // 最后活跃时间 + | 'status'; // 用户状态 + +/** + * 字段条件 + */ +export interface FieldCondition { + field: RuleField; + operator: RuleOperator; + value: number | string | string[]; +} + +/** + * 组合条件 + */ +export interface GroupCondition { + type: 'AND' | 'OR'; + rules: (FieldCondition | GroupCondition)[]; +} + +/** + * 规则条件 (顶层) + */ +export type RuleConditions = GroupCondition; + +/** + * 用户分类规则实体 + */ +export class ClassificationRuleEntity { + constructor( + public readonly id: string, + public readonly name: string, + public readonly description: string | null, + public readonly conditions: RuleConditions, + public readonly isEnabled: boolean, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} + + /** + * 创建新规则 + */ + static create(params: { + id: string; + name: string; + description?: string | null; + conditions: RuleConditions; + }): ClassificationRuleEntity { + const now = new Date(); + return new ClassificationRuleEntity( + params.id, + params.name, + params.description ?? null, + params.conditions, + true, + now, + now, + ); + } + + /** + * 更新规则 + */ + update(params: { + name?: string; + description?: string | null; + conditions?: RuleConditions; + isEnabled?: boolean; + }): ClassificationRuleEntity { + return new ClassificationRuleEntity( + this.id, + params.name ?? this.name, + params.description !== undefined ? params.description : this.description, + params.conditions ?? this.conditions, + params.isEnabled ?? this.isEnabled, + this.createdAt, + new Date(), + ); + } + + /** + * 启用规则 + */ + enable(): ClassificationRuleEntity { + return this.update({ isEnabled: true }); + } + + /** + * 禁用规则 + */ + disable(): ClassificationRuleEntity { + return this.update({ isEnabled: false }); + } + + /** + * 验证规则条件格式是否有效 + */ + static validateConditions(conditions: unknown): conditions is RuleConditions { + if (!conditions || typeof conditions !== 'object') { + return false; + } + + const group = conditions as GroupCondition; + if (!['AND', 'OR'].includes(group.type)) { + return false; + } + + if (!Array.isArray(group.rules) || group.rules.length === 0) { + return false; + } + + return group.rules.every((rule) => { + if ('type' in rule) { + // 递归验证嵌套组 + return ClassificationRuleEntity.validateConditions(rule); + } else { + // 验证字段条件 + return ClassificationRuleEntity.validateFieldCondition(rule); + } + }); + } + + /** + * 验证字段条件 + */ + private static validateFieldCondition(condition: unknown): condition is FieldCondition { + if (!condition || typeof condition !== 'object') { + return false; + } + + const field = condition as FieldCondition; + const validFields: RuleField[] = [ + 'registeredAt', + 'personalAdoptionCount', + 'teamAdoptionCount', + 'teamAddressCount', + 'provinceAdoptionCount', + 'cityAdoptionCount', + 'kycStatus', + 'lastActiveAt', + 'status', + ]; + + const validOperators: RuleOperator[] = [ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'within_days', + 'before_days', + 'in', + 'not_in', + ]; + + if (!validFields.includes(field.field)) { + return false; + } + + if (!validOperators.includes(field.operator)) { + return false; + } + + if (field.value === undefined || field.value === null) { + return false; + } + + return true; + } +} diff --git a/backend/services/admin-service/src/domain/entities/notification.entity.ts b/backend/services/admin-service/src/domain/entities/notification.entity.ts index da6152f0..192021fe 100644 --- a/backend/services/admin-service/src/domain/entities/notification.entity.ts +++ b/backend/services/admin-service/src/domain/entities/notification.entity.ts @@ -1,3 +1,13 @@ +/** + * 通知目标配置 + */ +export interface NotificationTarget { + type: TargetType; + tagIds?: string[]; // 标签ID列表 (AND 关系) + segmentId?: string; // 人群包ID + accountSequences?: string[]; // 特定用户列表 +} + /** * 通知实体 */ @@ -9,6 +19,7 @@ export class NotificationEntity { public readonly type: NotificationType, public readonly priority: NotificationPriority, public readonly targetType: TargetType, + public readonly targetConfig: NotificationTarget | null, public readonly imageUrl: string | null, public readonly linkUrl: string | null, public readonly isEnabled: boolean, @@ -46,6 +57,35 @@ export class NotificationEntity { return this.expiresAt < new Date(); } + /** + * 检查是否为定向通知 + */ + isTargeted(): boolean { + return this.targetType !== TargetType.ALL; + } + + /** + * 检查是否需要标签过滤 + */ + requiresTagFilter(): boolean { + return ( + this.targetType === TargetType.BY_TAG && + this.targetConfig?.tagIds !== undefined && + this.targetConfig.tagIds.length > 0 + ); + } + + /** + * 检查是否为特定用户通知 + */ + isSpecificUsers(): boolean { + return ( + this.targetType === TargetType.SPECIFIC && + this.targetConfig?.accountSequences !== undefined && + this.targetConfig.accountSequences.length > 0 + ); + } + /** * 创建新通知 */ @@ -56,6 +96,7 @@ export class NotificationEntity { type: NotificationType; priority?: NotificationPriority; targetType?: TargetType; + targetConfig?: NotificationTarget | null; imageUrl?: string | null; linkUrl?: string | null; publishedAt?: Date | null; @@ -63,13 +104,27 @@ export class NotificationEntity { createdBy: string; }): NotificationEntity { const now = new Date(); + const targetType = params.targetType ?? TargetType.ALL; + + // 构建目标配置 + let targetConfig: NotificationTarget | null = null; + if (params.targetConfig) { + targetConfig = { + ...params.targetConfig, + type: targetType, + }; + } else if (targetType !== TargetType.ALL) { + targetConfig = { type: targetType }; + } + return new NotificationEntity( params.id, params.title, params.content, params.type, params.priority ?? NotificationPriority.NORMAL, - params.targetType ?? TargetType.ALL, + targetType, + targetConfig, params.imageUrl ?? null, params.linkUrl ?? null, true, @@ -80,6 +135,41 @@ export class NotificationEntity { params.createdBy, ); } + + /** + * 更新通知 + */ + update(params: { + title?: string; + content?: string; + type?: NotificationType; + priority?: NotificationPriority; + targetType?: TargetType; + targetConfig?: NotificationTarget | null; + imageUrl?: string | null; + linkUrl?: string | null; + isEnabled?: boolean; + publishedAt?: Date | null; + expiresAt?: Date | null; + }): NotificationEntity { + return new NotificationEntity( + this.id, + params.title ?? this.title, + params.content ?? this.content, + params.type ?? this.type, + params.priority ?? this.priority, + params.targetType ?? this.targetType, + params.targetConfig !== undefined ? params.targetConfig : this.targetConfig, + params.imageUrl !== undefined ? params.imageUrl : this.imageUrl, + params.linkUrl !== undefined ? params.linkUrl : this.linkUrl, + params.isEnabled ?? this.isEnabled, + params.publishedAt !== undefined ? params.publishedAt : this.publishedAt, + params.expiresAt !== undefined ? params.expiresAt : this.expiresAt, + this.createdAt, + new Date(), + this.createdBy, + ); + } } /** @@ -105,9 +195,10 @@ export enum NotificationPriority { /** * 目标用户类型 + * 注意:与 Prisma schema 中的 TargetType 枚举保持一致 */ export enum TargetType { - ALL = 'ALL', - NEW_USER = 'NEW_USER', - VIP = 'VIP', + ALL = 'ALL', // 所有用户 + BY_TAG = 'BY_TAG', // 按标签筛选 + SPECIFIC = 'SPECIFIC', // 特定用户列表 } diff --git a/backend/services/admin-service/src/domain/entities/user-feature.entity.ts b/backend/services/admin-service/src/domain/entities/user-feature.entity.ts new file mode 100644 index 00000000..496447a0 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/user-feature.entity.ts @@ -0,0 +1,225 @@ +import { Decimal } from '@prisma/client/runtime/library'; + +/** + * 活跃度等级 + */ +export enum ActiveLevel { + HIGH = '高活跃', + MEDIUM = '中活跃', + LOW = '低活跃', + SILENT = '沉默', +} + +/** + * 价值等级 + */ +export enum ValueLevel { + HIGH = '高价值', + MEDIUM = '中价值', + LOW = '低价值', + POTENTIAL = '潜力', +} + +/** + * 生命周期阶段 + */ +export enum LifecycleStage { + NEW = '新用户', + GROWTH = '成长期', + MATURE = '成熟期', + DECLINE = '衰退期', + CHURNED = '流失', +} + +/** + * 用户特征实体 - 计算后的用户画像指标 + */ +export class UserFeatureEntity { + constructor( + public readonly id: string, + public readonly accountSequence: string, + // RFM 模型 + public readonly rfmRecency: number | null, + public readonly rfmFrequency: number | null, + public readonly rfmMonetary: Decimal | null, + public readonly rfmScore: number | null, + // 活跃度 + public readonly activeLevel: string | null, + public readonly lastActiveAt: Date | null, + // 价值分层 + public readonly valueLevel: string | null, + public readonly lifetimeValue: Decimal | null, + // 生命周期 + public readonly lifecycleStage: string | null, + // 自定义特征 + public readonly customFeatures: Record | null, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} + + /** + * 创建用户特征 + */ + static create(params: { + id: string; + accountSequence: string; + }): UserFeatureEntity { + const now = new Date(); + return new UserFeatureEntity( + params.id, + params.accountSequence, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + now, + now, + ); + } + + /** + * 更新 RFM 指标 + */ + updateRfm(params: { + recency?: number | null; + frequency?: number | null; + monetary?: Decimal | null; + score?: number | null; + }): UserFeatureEntity { + return new UserFeatureEntity( + this.id, + this.accountSequence, + params.recency !== undefined ? params.recency : this.rfmRecency, + params.frequency !== undefined ? params.frequency : this.rfmFrequency, + params.monetary !== undefined ? params.monetary : this.rfmMonetary, + params.score !== undefined ? params.score : this.rfmScore, + this.activeLevel, + this.lastActiveAt, + this.valueLevel, + this.lifetimeValue, + this.lifecycleStage, + this.customFeatures, + this.createdAt, + new Date(), + ); + } + + /** + * 更新活跃度 + */ + updateActiveLevel(level: string, lastActiveAt?: Date): UserFeatureEntity { + return new UserFeatureEntity( + this.id, + this.accountSequence, + this.rfmRecency, + this.rfmFrequency, + this.rfmMonetary, + this.rfmScore, + level, + lastActiveAt ?? this.lastActiveAt, + this.valueLevel, + this.lifetimeValue, + this.lifecycleStage, + this.customFeatures, + this.createdAt, + new Date(), + ); + } + + /** + * 更新价值分层 + */ + updateValueLevel(level: string, ltv?: Decimal): UserFeatureEntity { + return new UserFeatureEntity( + this.id, + this.accountSequence, + this.rfmRecency, + this.rfmFrequency, + this.rfmMonetary, + this.rfmScore, + this.activeLevel, + this.lastActiveAt, + level, + ltv !== undefined ? ltv : this.lifetimeValue, + this.lifecycleStage, + this.customFeatures, + this.createdAt, + new Date(), + ); + } + + /** + * 更新生命周期阶段 + */ + updateLifecycleStage(stage: string): UserFeatureEntity { + return new UserFeatureEntity( + this.id, + this.accountSequence, + this.rfmRecency, + this.rfmFrequency, + this.rfmMonetary, + this.rfmScore, + this.activeLevel, + this.lastActiveAt, + this.valueLevel, + this.lifetimeValue, + stage, + this.customFeatures, + this.createdAt, + new Date(), + ); + } + + /** + * 更新自定义特征 + */ + updateCustomFeatures(features: Record): UserFeatureEntity { + return new UserFeatureEntity( + this.id, + this.accountSequence, + this.rfmRecency, + this.rfmFrequency, + this.rfmMonetary, + this.rfmScore, + this.activeLevel, + this.lastActiveAt, + this.valueLevel, + this.lifetimeValue, + this.lifecycleStage, + { ...this.customFeatures, ...features }, + this.createdAt, + new Date(), + ); + } + + /** + * 计算 RFM 综合分数 (0-100) + * R: 最近活跃度 (越近越好) + * F: 活跃频率 (越频繁越好) + * M: 消费金额 (越多越好) + */ + static calculateRfmScore( + recency: number, + frequency: number, + monetary: number, + weights = { r: 0.3, f: 0.3, m: 0.4 }, + ): number { + // 归一化分数 (0-100) + // Recency: 0-30天 = 100分, 30天以上递减 + const rScore = Math.max(0, Math.min(100, 100 - (recency / 30) * 100)); + + // Frequency: 30天内活跃天数,30天 = 100分 + const fScore = Math.min(100, (frequency / 30) * 100); + + // Monetary: 根据业务定义,这里假设 10000 = 100分 + const mScore = Math.min(100, (monetary / 10000) * 100); + + return Math.round(rScore * weights.r + fScore * weights.f + mScore * weights.m); + } +} diff --git a/backend/services/admin-service/src/domain/entities/user-tag.entity.ts b/backend/services/admin-service/src/domain/entities/user-tag.entity.ts new file mode 100644 index 00000000..190bb7bc --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/user-tag.entity.ts @@ -0,0 +1,441 @@ +/** + * 标签类型 + */ +export enum TagType { + MANUAL = 'MANUAL', // 手动打标 (管理员操作) + AUTO = 'AUTO', // 自动打标 (规则驱动) + COMPUTED = 'COMPUTED', // 计算型 (实时计算,不存储关联) + SYSTEM = 'SYSTEM', // 系统内置 (不可删除) +} + +/** + * 标签值类型 + */ +export enum TagValueType { + BOOLEAN = 'BOOLEAN', // 布尔型: 有/无 + ENUM = 'ENUM', // 枚举型: 高/中/低 + NUMBER = 'NUMBER', // 数值型: 0-100分 + STRING = 'STRING', // 字符串型 +} + +/** + * 标签变更操作 + */ +export enum TagAction { + ASSIGN = 'ASSIGN', // 打标签 + UPDATE = 'UPDATE', // 更新标签值 + REMOVE = 'REMOVE', // 移除标签 + EXPIRE = 'EXPIRE', // 过期移除 +} + +/** + * 标签分类实体 + */ +export class TagCategoryEntity { + constructor( + public readonly id: string, + public readonly code: string, + public readonly name: string, + public readonly description: string | null, + public readonly sortOrder: number, + public readonly isEnabled: boolean, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} + + static create(params: { + id: string; + code: string; + name: string; + description?: string | null; + sortOrder?: number; + }): TagCategoryEntity { + const now = new Date(); + return new TagCategoryEntity( + params.id, + params.code, + params.name, + params.description ?? null, + params.sortOrder ?? 0, + true, + now, + now, + ); + } + + update(params: { + name?: string; + description?: string | null; + sortOrder?: number; + isEnabled?: boolean; + }): TagCategoryEntity { + return new TagCategoryEntity( + this.id, + this.code, + params.name ?? this.name, + params.description !== undefined ? params.description : this.description, + params.sortOrder ?? this.sortOrder, + params.isEnabled ?? this.isEnabled, + this.createdAt, + new Date(), + ); + } +} + +/** + * 用户标签实体 + */ +export class UserTagEntity { + constructor( + public readonly id: string, + public readonly categoryId: string | null, + public readonly code: string, + public readonly name: string, + public readonly description: string | null, + public readonly color: string | null, + public readonly type: TagType, + public readonly valueType: TagValueType, + public readonly enumValues: string[] | null, + public readonly ruleId: string | null, + public readonly isAdvertisable: boolean, + public readonly estimatedUsers: number | null, + public readonly isEnabled: boolean, + public readonly sortOrder: number, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} + + /** + * 创建新标签 + */ + static create(params: { + id: string; + code: string; + name: string; + categoryId?: string | null; + description?: string | null; + color?: string | null; + type?: TagType; + valueType?: TagValueType; + enumValues?: string[] | null; + ruleId?: string | null; + isAdvertisable?: boolean; + sortOrder?: number; + }): UserTagEntity { + const now = new Date(); + return new UserTagEntity( + params.id, + params.categoryId ?? null, + params.code, + params.name, + params.description ?? null, + params.color ?? null, + params.type ?? TagType.MANUAL, + params.valueType ?? TagValueType.BOOLEAN, + params.enumValues ?? null, + params.ruleId ?? null, + params.isAdvertisable ?? true, + null, + true, + params.sortOrder ?? 0, + now, + now, + ); + } + + /** + * 更新标签 + */ + update(params: { + categoryId?: string | null; + name?: string; + description?: string | null; + color?: string | null; + valueType?: TagValueType; + enumValues?: string[] | null; + isAdvertisable?: boolean; + isEnabled?: boolean; + sortOrder?: number; + }): UserTagEntity { + return new UserTagEntity( + this.id, + params.categoryId !== undefined ? params.categoryId : this.categoryId, + this.code, + params.name ?? this.name, + params.description !== undefined ? params.description : this.description, + params.color !== undefined ? params.color : this.color, + this.type, + params.valueType ?? this.valueType, + params.enumValues !== undefined ? params.enumValues : this.enumValues, + this.ruleId, + params.isAdvertisable ?? this.isAdvertisable, + this.estimatedUsers, + params.isEnabled ?? this.isEnabled, + params.sortOrder ?? this.sortOrder, + this.createdAt, + new Date(), + ); + } + + /** + * 关联规则 + */ + linkRule(ruleId: string): UserTagEntity { + return new UserTagEntity( + this.id, + this.categoryId, + this.code, + this.name, + this.description, + this.color, + TagType.AUTO, + this.valueType, + this.enumValues, + ruleId, + this.isAdvertisable, + this.estimatedUsers, + this.isEnabled, + this.sortOrder, + this.createdAt, + new Date(), + ); + } + + /** + * 取消关联规则 + */ + unlinkRule(): UserTagEntity { + return new UserTagEntity( + this.id, + this.categoryId, + this.code, + this.name, + this.description, + this.color, + TagType.MANUAL, + this.valueType, + this.enumValues, + null, + this.isAdvertisable, + this.estimatedUsers, + this.isEnabled, + this.sortOrder, + this.createdAt, + new Date(), + ); + } + + /** + * 更新预估用户数 + */ + updateEstimatedUsers(count: number): UserTagEntity { + return new UserTagEntity( + this.id, + this.categoryId, + this.code, + this.name, + this.description, + this.color, + this.type, + this.valueType, + this.enumValues, + this.ruleId, + this.isAdvertisable, + count, + this.isEnabled, + this.sortOrder, + this.createdAt, + new Date(), + ); + } + + /** + * 是否为自动标签 + */ + isAutoTag(): boolean { + return this.type === TagType.AUTO && this.ruleId !== null; + } + + /** + * 是否可删除 + */ + canDelete(): boolean { + return this.type !== TagType.SYSTEM; + } + + /** + * 验证枚举值是否有效 + */ + isValidEnumValue(value: string): boolean { + if (this.valueType !== TagValueType.ENUM) { + return true; + } + if (!this.enumValues || this.enumValues.length === 0) { + return true; + } + return this.enumValues.includes(value); + } +} + +/** + * 用户-标签关联实体 + */ +export class UserTagAssignmentEntity { + constructor( + public readonly id: string, + public readonly accountSequence: string, + public readonly tagId: string, + public readonly value: string | null, + public readonly assignedAt: Date, + public readonly assignedBy: string | null, + public readonly expiresAt: Date | null, + public readonly source: string | null, + ) {} + + /** + * 创建关联 (手动打标) + */ + static createManual(params: { + id: string; + accountSequence: string; + tagId: string; + value?: string | null; + assignedBy: string; + expiresAt?: Date | null; + }): UserTagAssignmentEntity { + return new UserTagAssignmentEntity( + params.id, + params.accountSequence, + params.tagId, + params.value ?? null, + new Date(), + params.assignedBy, + params.expiresAt ?? null, + 'manual', + ); + } + + /** + * 创建关联 (自动打标) + */ + static createAuto(params: { + id: string; + accountSequence: string; + tagId: string; + value?: string | null; + ruleId?: string; + }): UserTagAssignmentEntity { + return new UserTagAssignmentEntity( + params.id, + params.accountSequence, + params.tagId, + params.value ?? null, + new Date(), + null, + null, + params.ruleId ? `rule:${params.ruleId}` : 'auto', + ); + } + + /** + * 创建关联 (导入) + */ + static createFromImport(params: { + id: string; + accountSequence: string; + tagId: string; + value?: string | null; + expiresAt?: Date | null; + }): UserTagAssignmentEntity { + return new UserTagAssignmentEntity( + params.id, + params.accountSequence, + params.tagId, + params.value ?? null, + new Date(), + null, + params.expiresAt ?? null, + 'import', + ); + } + + /** + * 更新标签值 + */ + updateValue(value: string | null): UserTagAssignmentEntity { + return new UserTagAssignmentEntity( + this.id, + this.accountSequence, + this.tagId, + value, + this.assignedAt, + this.assignedBy, + this.expiresAt, + this.source, + ); + } + + /** + * 是否已过期 + */ + isExpired(): boolean { + if (!this.expiresAt) { + return false; + } + return this.expiresAt < new Date(); + } + + /** + * 是否为手动打标 + */ + isManual(): boolean { + return this.source === 'manual'; + } + + /** + * 是否为自动打标 + */ + isAuto(): boolean { + return this.source?.startsWith('rule:') || this.source === 'auto'; + } +} + +/** + * 标签变更日志实体 + */ +export class UserTagLogEntity { + constructor( + public readonly id: string, + public readonly accountSequence: string, + public readonly tagCode: string, + public readonly action: TagAction, + public readonly oldValue: string | null, + public readonly newValue: string | null, + public readonly reason: string | null, + public readonly operatorId: string | null, + public readonly createdAt: Date, + ) {} + + static create(params: { + id: string; + accountSequence: string; + tagCode: string; + action: TagAction; + oldValue?: string | null; + newValue?: string | null; + reason?: string | null; + operatorId?: string | null; + }): UserTagLogEntity { + return new UserTagLogEntity( + params.id, + params.accountSequence, + params.tagCode, + params.action, + params.oldValue ?? null, + params.newValue ?? null, + params.reason ?? null, + params.operatorId ?? null, + new Date(), + ); + } +} diff --git a/backend/services/admin-service/src/domain/repositories/audience-segment.repository.ts b/backend/services/admin-service/src/domain/repositories/audience-segment.repository.ts new file mode 100644 index 00000000..49adfbca --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/audience-segment.repository.ts @@ -0,0 +1,54 @@ +import { + AudienceSegmentEntity, + SegmentUsageType, +} from '../entities/audience-segment.entity'; + +export const AUDIENCE_SEGMENT_REPOSITORY = Symbol('AUDIENCE_SEGMENT_REPOSITORY'); + +/** + * 人群包仓储接口 + */ +export interface AudienceSegmentRepository { + /** + * 保存人群包 + */ + save(segment: AudienceSegmentEntity): Promise; + + /** + * 根据 ID 查找 + */ + findById(id: string): Promise; + + /** + * 查找所有人群包 + */ + findAll(params?: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise; + + /** + * 根据用途类型查找 + */ + findByUsageType(usageType: SegmentUsageType): Promise; + + /** + * 统计人群包数量 + */ + count(params?: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + }): Promise; + + /** + * 删除人群包 + */ + delete(id: string): Promise; + + /** + * 更新预估用户数 + */ + updateEstimatedUsers(id: string, count: number): Promise; +} diff --git a/backend/services/admin-service/src/domain/repositories/classification-rule.repository.ts b/backend/services/admin-service/src/domain/repositories/classification-rule.repository.ts new file mode 100644 index 00000000..3c532bee --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/classification-rule.repository.ts @@ -0,0 +1,58 @@ +import { ClassificationRuleEntity } from '../entities/classification-rule.entity'; + +export const CLASSIFICATION_RULE_REPOSITORY = Symbol('CLASSIFICATION_RULE_REPOSITORY'); + +/** + * 用户分类规则仓储接口 + */ +export interface ClassificationRuleRepository { + /** + * 保存规则 + */ + save(rule: ClassificationRuleEntity): Promise; + + /** + * 根据ID查找规则 + */ + findById(id: string): Promise; + + /** + * 查找所有规则 + */ + findAll(params?: { + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise; + + /** + * 查找所有启用的规则 + */ + findEnabled(): Promise; + + /** + * 查找已关联标签的规则 + */ + findLinkedRules(): Promise; + + /** + * 统计规则数量 + */ + count(params?: { + isEnabled?: boolean; + }): Promise; + + /** + * 删除规则 + */ + delete(id: string): Promise; +} + +/** + * 规则及其关联的标签 + */ +export interface RuleWithTag { + rule: ClassificationRuleEntity; + tagId: string; + tagCode: string; +} diff --git a/backend/services/admin-service/src/domain/repositories/user-tag.repository.ts b/backend/services/admin-service/src/domain/repositories/user-tag.repository.ts new file mode 100644 index 00000000..2131ead7 --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/user-tag.repository.ts @@ -0,0 +1,111 @@ +import { + UserTagEntity, + UserTagAssignmentEntity, + TagCategoryEntity, + UserTagLogEntity, + TagType, + TagAction, +} from '../entities/user-tag.entity'; + +export const USER_TAG_REPOSITORY = Symbol('USER_TAG_REPOSITORY'); + +/** + * 用户标签仓储接口 + */ +export interface UserTagRepository { + // ===================== + // 标签分类 CRUD + // ===================== + + saveCategory(category: TagCategoryEntity): Promise; + findCategoryById(id: string): Promise; + findCategoryByCode(code: string): Promise; + findAllCategories(params?: { isEnabled?: boolean }): Promise; + deleteCategory(id: string): Promise; + + // ===================== + // 标签定义 CRUD + // ===================== + + saveTag(tag: UserTagEntity): Promise; + findTagById(id: string): Promise; + findTagByCode(code: string): Promise; + findAllTags(params?: { + categoryId?: string; + type?: TagType; + isEnabled?: boolean; + isAdvertisable?: boolean; + limit?: number; + offset?: number; + }): Promise; + findEnabledTags(): Promise; + findAutoTags(): Promise; + findAdvertisableTags(): Promise; + countTags(params?: { + categoryId?: string; + type?: TagType; + isEnabled?: boolean; + }): Promise; + deleteTag(id: string): Promise; + updateEstimatedUsers(tagId: string, count: number): Promise; + + // ===================== + // 用户-标签关联 + // ===================== + + saveAssignment(assignment: UserTagAssignmentEntity): Promise; + saveAssignments(assignments: UserTagAssignmentEntity[]): Promise; + findUserTags(accountSequence: string): Promise; + findUserTagIds(accountSequence: string): Promise; + findUsersByTagId(tagId: string, params?: { + value?: string; + limit?: number; + offset?: number; + }): Promise; + findUsersByTagCode(tagCode: string, params?: { + value?: string; + limit?: number; + offset?: number; + }): Promise; + countUsersByTagId(tagId: string, value?: string): Promise; + hasTag(accountSequence: string, tagId: string): Promise; + hasAnyTag(accountSequence: string, tagIds: string[]): Promise; + hasAllTags(accountSequence: string, tagIds: string[]): Promise; + getAssignment(accountSequence: string, tagId: string): Promise; + deleteAssignment(accountSequence: string, tagId: string): Promise; + deleteAutoAssignments(accountSequence: string): Promise; + deleteAssignmentsByTagId(tagId: string): Promise; + deleteAssignmentsBySource(source: string): Promise; + cleanExpiredAssignments(): Promise; + + // ===================== + // 标签变更日志 + // ===================== + + saveLog(log: UserTagLogEntity): Promise; + saveLogs(logs: UserTagLogEntity[]): Promise; + findLogs(params: { + accountSequence?: string; + tagCode?: string; + action?: TagAction; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; + }): Promise; + countLogs(params: { + accountSequence?: string; + tagCode?: string; + action?: TagAction; + startDate?: Date; + endDate?: Date; + }): Promise; +} + +/** + * 用户标签(包含关联信息) + */ +export interface UserTagWithAssignment { + tag: UserTagEntity; + assignment: UserTagAssignmentEntity; +} diff --git a/backend/services/admin-service/src/infrastructure/jobs/auto-tag-sync.job.ts b/backend/services/admin-service/src/infrastructure/jobs/auto-tag-sync.job.ts new file mode 100644 index 00000000..6ca4519f --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/jobs/auto-tag-sync.job.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { UserTaggingService } from '../../application/services/user-tagging.service'; +import { AudienceSegmentService } from '../../application/services/audience-segment.service'; + +/** + * 自动标签同步定时任务 + * 负责定期执行: + * 1. 自动标签同步(根据规则给用户打标签) + * 2. 过期标签清理 + * 3. 人群包预估用户数更新 + */ +@Injectable() +export class AutoTagSyncJob implements OnModuleInit { + private readonly logger = new Logger(AutoTagSyncJob.name); + private isRunning = false; + + constructor( + private readonly taggingService: UserTaggingService, + private readonly segmentService: AudienceSegmentService, + ) {} + + onModuleInit() { + this.logger.log('AutoTagSyncJob initialized'); + } + + /** + * 每天凌晨 2 点执行自动标签同步 + */ + @Cron('0 2 * * *') + async syncAutoTags(): Promise { + if (this.isRunning) { + this.logger.warn('Auto tag sync is already running, skipping...'); + return; + } + + this.isRunning = true; + this.logger.log('Starting auto tag sync...'); + + try { + const result = await this.taggingService.syncAllAutoTags({ + batchSize: 100, + onProgress: (processed, total) => { + if (processed % 1000 === 0) { + this.logger.log(`Auto tag sync progress: ${processed}/${total}`); + } + }, + }); + + this.logger.log( + `Auto tag sync completed: ${result.processed}/${result.total} users processed, ${result.errors.length} errors`, + ); + + // 记录统计信息 + const addedCount = result.results.reduce( + (sum, r) => sum + r.added.length, + 0, + ); + const removedCount = result.results.reduce( + (sum, r) => sum + r.removed.length, + 0, + ); + + this.logger.log( + `Auto tag sync stats: ${addedCount} tags added, ${removedCount} tags removed`, + ); + } catch (error) { + this.logger.error(`Auto tag sync failed: ${error}`); + } finally { + this.isRunning = false; + } + } + + /** + * 每天凌晨 3 点清理过期标签 + */ + @Cron('0 3 * * *') + async cleanExpiredTags(): Promise { + this.logger.log('Starting expired tag cleanup...'); + + try { + const count = await this.taggingService.cleanExpiredTags(); + this.logger.log(`Cleaned ${count} expired tag assignments`); + } catch (error) { + this.logger.error(`Expired tag cleanup failed: ${error}`); + } + } + + /** + * 每天凌晨 4 点更新人群包预估用户数 + */ + @Cron('0 4 * * *') + async refreshSegmentEstimates(): Promise { + this.logger.log('Starting segment estimate refresh...'); + + try { + await this.segmentService.recalculateAllEstimatedUsers(); + this.logger.log('Segment estimate refresh completed'); + } catch (error) { + this.logger.error(`Segment estimate refresh failed: ${error}`); + } + } + + /** + * 手动触发自动标签同步(供 API 调用) + */ + async triggerSync(): Promise<{ + processed: number; + total: number; + errors: number; + }> { + if (this.isRunning) { + throw new Error('Auto tag sync is already running'); + } + + this.isRunning = true; + + try { + const result = await this.taggingService.syncAllAutoTags(); + return { + processed: result.processed, + total: result.total, + errors: result.errors.length, + }; + } finally { + this.isRunning = false; + } + } + + /** + * 获取同步状态 + */ + getSyncStatus(): { isRunning: boolean } { + return { isRunning: this.isRunning }; + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts index 6e0d934e..56938bdb 100644 --- a/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts +++ b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts @@ -10,18 +10,49 @@ import { NotificationType, NotificationPriority, TargetType, + NotificationTarget, } from '../../../domain/entities/notification.entity'; +/** + * 带关联数据的通知 + */ +export interface NotificationWithTargets extends PrismaNotification { + targetTags?: { tagId: string }[]; + targetUsers?: { accountSequence: string }[]; +} + @Injectable() export class NotificationMapper { - toDomain(prisma: PrismaNotification): NotificationEntity { + toDomain(prisma: NotificationWithTargets): NotificationEntity { + // 构建目标配置 + let targetConfig: NotificationTarget | null = null; + const targetType = prisma.targetType as TargetType; + + if (targetType === TargetType.BY_TAG && prisma.targetTags?.length) { + targetConfig = { + type: targetType, + tagIds: prisma.targetTags.map((t) => t.tagId), + }; + } else if ( + targetType === TargetType.SPECIFIC && + prisma.targetUsers?.length + ) { + targetConfig = { + type: targetType, + accountSequences: prisma.targetUsers.map((u) => u.accountSequence), + }; + } else if (targetType !== TargetType.ALL) { + targetConfig = { type: targetType }; + } + return new NotificationEntity( prisma.id, prisma.title, prisma.content, prisma.type as NotificationType, prisma.priority as NotificationPriority, - prisma.targetType as TargetType, + targetType, + targetConfig, prisma.imageUrl, prisma.linkUrl, prisma.isEnabled, @@ -33,7 +64,9 @@ export class NotificationMapper { ); } - toPersistence(entity: NotificationEntity): Omit & { id: string } { + toPersistence( + entity: NotificationEntity, + ): Omit & { id: string } { return { id: entity.id, title: entity.title, @@ -41,6 +74,7 @@ export class NotificationMapper { type: entity.type as PrismaNotificationType, priority: entity.priority as PrismaPriority, targetType: entity.targetType as PrismaTargetType, + targetLogic: 'ANY', // 默认 ANY,后续可扩展 imageUrl: entity.imageUrl, linkUrl: entity.linkUrl, isEnabled: entity.isEnabled, diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/audience-segment.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/audience-segment.repository.impl.ts new file mode 100644 index 00000000..576aa6cc --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/audience-segment.repository.impl.ts @@ -0,0 +1,145 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AudienceSegmentRepository } from '../../../domain/repositories/audience-segment.repository'; +import { + AudienceSegmentEntity, + SegmentConditionGroup, + SegmentUsageType, +} from '../../../domain/entities/audience-segment.entity'; + +@Injectable() +export class AudienceSegmentRepositoryImpl implements AudienceSegmentRepository { + constructor(private readonly prisma: PrismaService) {} + + async save(segment: AudienceSegmentEntity): Promise { + const data = { + id: segment.id, + name: segment.name, + description: segment.description, + conditions: segment.conditions as object, + estimatedUsers: segment.estimatedUsers, + lastCalculated: segment.lastCalculated, + usageType: segment.usageType, + isEnabled: segment.isEnabled, + createdBy: segment.createdBy, + updatedAt: new Date(), + }; + + const result = await this.prisma.audienceSegment.upsert({ + where: { id: segment.id }, + create: { + ...data, + createdAt: segment.createdAt, + }, + update: data, + }); + + return this.mapToEntity(result); + } + + async findById(id: string): Promise { + const result = await this.prisma.audienceSegment.findUnique({ + where: { id }, + }); + + return result ? this.mapToEntity(result) : null; + } + + async findAll(params?: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise { + const where: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + } = {}; + + if (params?.usageType) { + where.usageType = params.usageType; + } + if (params?.isEnabled !== undefined) { + where.isEnabled = params.isEnabled; + } + + const results = await this.prisma.audienceSegment.findMany({ + where, + take: params?.limit, + skip: params?.offset, + orderBy: { createdAt: 'desc' }, + }); + + return results.map((r) => this.mapToEntity(r)); + } + + async findByUsageType( + usageType: SegmentUsageType, + ): Promise { + return this.findAll({ usageType, isEnabled: true }); + } + + async count(params?: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + }): Promise { + const where: { + usageType?: SegmentUsageType; + isEnabled?: boolean; + } = {}; + + if (params?.usageType) { + where.usageType = params.usageType; + } + if (params?.isEnabled !== undefined) { + where.isEnabled = params.isEnabled; + } + + return this.prisma.audienceSegment.count({ where }); + } + + async delete(id: string): Promise { + await this.prisma.audienceSegment.delete({ + where: { id }, + }); + } + + async updateEstimatedUsers(id: string, count: number): Promise { + await this.prisma.audienceSegment.update({ + where: { id }, + data: { + estimatedUsers: count, + lastCalculated: new Date(), + updatedAt: new Date(), + }, + }); + } + + private mapToEntity(data: { + id: string; + name: string; + description: string | null; + conditions: unknown; + estimatedUsers: number | null; + lastCalculated: Date | null; + usageType: string; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; + createdBy: string; + }): AudienceSegmentEntity { + return new AudienceSegmentEntity( + data.id, + data.name, + data.description, + data.conditions as SegmentConditionGroup, + data.estimatedUsers, + data.lastCalculated, + data.usageType as SegmentUsageType, + data.isEnabled, + data.createdAt, + data.updatedAt, + data.createdBy, + ); + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/classification-rule.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/classification-rule.repository.impl.ts new file mode 100644 index 00000000..c6b3ff46 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/classification-rule.repository.impl.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ClassificationRuleRepository, + RuleWithTag, +} from '../../../domain/repositories/classification-rule.repository'; +import { + ClassificationRuleEntity, + RuleConditions, +} from '../../../domain/entities/classification-rule.entity'; + +@Injectable() +export class ClassificationRuleRepositoryImpl + implements ClassificationRuleRepository +{ + constructor(private readonly prisma: PrismaService) {} + + async save(rule: ClassificationRuleEntity): Promise { + const data = { + id: rule.id, + name: rule.name, + description: rule.description, + conditions: rule.conditions as object, + isEnabled: rule.isEnabled, + updatedAt: new Date(), + }; + + const result = await this.prisma.userClassificationRule.upsert({ + where: { id: rule.id }, + create: { + ...data, + createdAt: rule.createdAt, + }, + update: data, + }); + + return this.mapToEntity(result); + } + + async findById(id: string): Promise { + const result = await this.prisma.userClassificationRule.findUnique({ + where: { id }, + }); + + return result ? this.mapToEntity(result) : null; + } + + async findAll(params?: { + isEnabled?: boolean; + limit?: number; + offset?: number; + }): Promise { + const results = await this.prisma.userClassificationRule.findMany({ + where: params?.isEnabled !== undefined + ? { isEnabled: params.isEnabled } + : undefined, + take: params?.limit, + skip: params?.offset, + orderBy: { createdAt: 'desc' }, + }); + + return results.map((r) => this.mapToEntity(r)); + } + + async findEnabled(): Promise { + return this.findAll({ isEnabled: true }); + } + + async findLinkedRules(): Promise { + // 查找所有关联了标签的规则 + const tags = await this.prisma.userTag.findMany({ + where: { + ruleId: { not: null }, + }, + include: { + rule: true, + }, + }); + + return tags + .filter((tag) => tag.rule !== null) + .map((tag) => ({ + rule: this.mapToEntity(tag.rule!), + tagId: tag.id, + tagCode: tag.code, + })); + } + + async count(params?: { isEnabled?: boolean }): Promise { + return this.prisma.userClassificationRule.count({ + where: params?.isEnabled !== undefined + ? { isEnabled: params.isEnabled } + : undefined, + }); + } + + async delete(id: string): Promise { + // 先解除与标签的关联 + await this.prisma.userTag.updateMany({ + where: { ruleId: id }, + data: { ruleId: null, type: 'MANUAL' }, + }); + + await this.prisma.userClassificationRule.delete({ + where: { id }, + }); + } + + private mapToEntity(data: { + id: string; + name: string; + description: string | null; + conditions: unknown; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; + }): ClassificationRuleEntity { + return new ClassificationRuleEntity( + data.id, + data.name, + data.description, + data.conditions as RuleConditions, + data.isEnabled, + data.createdAt, + data.updatedAt, + ); + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts index f6780a4a..d2f7bef2 100644 --- a/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/notification.repository.impl.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationRepository, @@ -7,31 +7,100 @@ import { import { NotificationEntity, NotificationType, + TargetType, } from '../../../domain/entities/notification.entity'; -import { NotificationMapper } from '../mappers/notification.mapper'; +import { + NotificationMapper, + NotificationWithTargets, +} from '../mappers/notification.mapper'; +import { + USER_TAG_REPOSITORY, + UserTagRepository, +} from '../../../domain/repositories/user-tag.repository'; @Injectable() export class NotificationRepositoryImpl implements NotificationRepository { constructor( private readonly prisma: PrismaService, private readonly mapper: NotificationMapper, + @Inject(USER_TAG_REPOSITORY) + private readonly tagRepository: UserTagRepository, ) {} async save(notification: NotificationEntity): Promise { const data = this.mapper.toPersistence(notification); - const saved = await this.prisma.notification.upsert({ - where: { id: notification.id }, - create: data, - update: data, + + // 使用事务保存通知和关联数据 + const saved = await this.prisma.$transaction(async (tx) => { + // 保存通知主体 + const result = await tx.notification.upsert({ + where: { id: notification.id }, + create: data, + update: data, + }); + + // 删除旧的关联 + await tx.notificationTagTarget.deleteMany({ + where: { notificationId: notification.id }, + }); + await tx.notificationUserTarget.deleteMany({ + where: { notificationId: notification.id }, + }); + + // 保存新的标签关联 + if ( + notification.targetType === TargetType.BY_TAG && + notification.targetConfig?.tagIds?.length + ) { + await tx.notificationTagTarget.createMany({ + data: notification.targetConfig.tagIds.map((tagId) => ({ + notificationId: notification.id, + tagId, + })), + }); + } + + // 保存新的用户关联 + if ( + notification.targetType === TargetType.SPECIFIC && + notification.targetConfig?.accountSequences?.length + ) { + await tx.notificationUserTarget.createMany({ + data: notification.targetConfig.accountSequences.map( + (accountSequence) => ({ + notificationId: notification.id, + accountSequence, + }), + ), + }); + } + + return result; }); - return this.mapper.toDomain(saved); + + // 重新加载完整数据 + const complete = await this.prisma.notification.findUnique({ + where: { id: saved.id }, + include: { + targetTags: { select: { tagId: true } }, + targetUsers: { select: { accountSequence: true } }, + }, + }); + + return this.mapper.toDomain(complete as NotificationWithTargets); } async findById(id: string): Promise { const notification = await this.prisma.notification.findUnique({ where: { id }, + include: { + targetTags: { select: { tagId: true } }, + targetUsers: { select: { accountSequence: true } }, + }, }); - return notification ? this.mapper.toDomain(notification) : null; + return notification + ? this.mapper.toDomain(notification as NotificationWithTargets) + : null; } async findActiveNotifications(params?: { @@ -47,11 +116,17 @@ export class NotificationRepositoryImpl implements NotificationRepository { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], ...(params?.type && { type: params.type }), }, + include: { + targetTags: { select: { tagId: true } }, + targetUsers: { select: { accountSequence: true } }, + }, orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }], take: params?.limit ?? 50, skip: params?.offset ?? 0, }); - return notifications.map((n) => this.mapper.toDomain(n)); + return notifications.map((n) => + this.mapper.toDomain(n as NotificationWithTargets), + ); } async findNotificationsForUser(params: { @@ -61,18 +136,52 @@ export class NotificationRepositoryImpl implements NotificationRepository { offset?: number; }): Promise { const now = new Date(); + + // 获取用户的标签 + const userTagIds = await this.tagRepository.findUserTagIds( + params.userSerialNum, + ); + + // 查询通知 const notifications = await this.prisma.notification.findMany({ where: { isEnabled: true, publishedAt: { lte: now }, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], ...(params.type && { type: params.type }), + // 目标用户筛选 + AND: [ + { + OR: [ + // ALL: 发给所有人 + { targetType: 'ALL' }, + // BY_TAG: 用户必须有指定的标签 + { + targetType: 'BY_TAG', + targetTags: { + some: { + tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] }, + }, + }, + }, + // SPECIFIC: 指定给该用户 + { + targetType: 'SPECIFIC', + targetUsers: { + some: { accountSequence: params.userSerialNum }, + }, + }, + ], + }, + ], }, include: { readRecords: { where: { userSerialNum: params.userSerialNum }, take: 1, }, + targetTags: { select: { tagId: true } }, + targetUsers: { select: { accountSequence: true } }, }, orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }], take: params.limit ?? 50, @@ -80,7 +189,7 @@ export class NotificationRepositoryImpl implements NotificationRepository { }); return notifications.map((n) => ({ - notification: this.mapper.toDomain(n), + notification: this.mapper.toDomain(n as NotificationWithTargets), isRead: n.readRecords.length > 0, readAt: n.readRecords[0]?.readAt ?? null, })); @@ -88,6 +197,10 @@ export class NotificationRepositoryImpl implements NotificationRepository { async countUnreadForUser(userSerialNum: string): Promise { const now = new Date(); + + // 获取用户的标签 + const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum); + const count = await this.prisma.notification.count({ where: { isEnabled: true, @@ -96,12 +209,37 @@ export class NotificationRepositoryImpl implements NotificationRepository { readRecords: { none: { userSerialNum }, }, + // 目标用户筛选 + AND: [ + { + OR: [ + { targetType: 'ALL' }, + { + targetType: 'BY_TAG', + targetTags: { + some: { + tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] }, + }, + }, + }, + { + targetType: 'SPECIFIC', + targetUsers: { + some: { accountSequence: userSerialNum }, + }, + }, + ], + }, + ], }, }); return count; } - async markAsRead(notificationId: string, userSerialNum: string): Promise { + async markAsRead( + notificationId: string, + userSerialNum: string, + ): Promise { await this.prisma.notificationRead.upsert({ where: { notificationId_userSerialNum: { @@ -119,6 +257,10 @@ export class NotificationRepositoryImpl implements NotificationRepository { async markAllAsRead(userSerialNum: string): Promise { const now = new Date(); + + // 获取用户的标签 + const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum); + // 获取所有未读的有效通知 const unreadNotifications = await this.prisma.notification.findMany({ where: { @@ -128,6 +270,27 @@ export class NotificationRepositoryImpl implements NotificationRepository { readRecords: { none: { userSerialNum }, }, + AND: [ + { + OR: [ + { targetType: 'ALL' }, + { + targetType: 'BY_TAG', + targetTags: { + some: { + tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] }, + }, + }, + }, + { + targetType: 'SPECIFIC', + targetUsers: { + some: { accountSequence: userSerialNum }, + }, + }, + ], + }, + ], }, select: { id: true }, }); @@ -161,11 +324,17 @@ export class NotificationRepositoryImpl implements NotificationRepository { ...(params?.type && { type: params.type }), ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), }, + include: { + targetTags: { select: { tagId: true } }, + targetUsers: { select: { accountSequence: true } }, + }, orderBy: { createdAt: 'desc' }, take: params?.limit ?? 50, skip: params?.offset ?? 0, }); - return notifications.map((n) => this.mapper.toDomain(n)); + return notifications.map((n) => + this.mapper.toDomain(n as NotificationWithTargets), + ); } async count(params?: { diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/user-tag.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-tag.repository.impl.ts new file mode 100644 index 00000000..908d1ce3 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/user-tag.repository.impl.ts @@ -0,0 +1,647 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { + UserTagRepository, + UserTagWithAssignment, +} from '../../../domain/repositories/user-tag.repository'; +import { + UserTagEntity, + UserTagAssignmentEntity, + TagCategoryEntity, + UserTagLogEntity, + TagType, + TagValueType, + TagAction, +} from '../../../domain/entities/user-tag.entity'; +import { + Prisma, + TagType as PrismaTagType, + TagValueType as PrismaTagValueType, + TagAction as PrismaTagAction, +} from '@prisma/client'; + +@Injectable() +export class UserTagRepositoryImpl implements UserTagRepository { + private readonly logger = new Logger(UserTagRepositoryImpl.name); + + constructor(private readonly prisma: PrismaService) {} + + // ===================== + // 标签分类 CRUD + // ===================== + + async saveCategory(category: TagCategoryEntity): Promise { + const saved = await this.prisma.tagCategory.upsert({ + where: { id: category.id }, + create: { + id: category.id, + code: category.code, + name: category.name, + description: category.description, + sortOrder: category.sortOrder, + isEnabled: category.isEnabled, + createdAt: category.createdAt, + updatedAt: category.updatedAt, + }, + update: { + name: category.name, + description: category.description, + sortOrder: category.sortOrder, + isEnabled: category.isEnabled, + updatedAt: category.updatedAt, + }, + }); + return this.mapCategoryToDomain(saved); + } + + async findCategoryById(id: string): Promise { + const category = await this.prisma.tagCategory.findUnique({ where: { id } }); + return category ? this.mapCategoryToDomain(category) : null; + } + + async findCategoryByCode(code: string): Promise { + const category = await this.prisma.tagCategory.findUnique({ where: { code } }); + return category ? this.mapCategoryToDomain(category) : null; + } + + async findAllCategories(params?: { isEnabled?: boolean }): Promise { + const categories = await this.prisma.tagCategory.findMany({ + where: { + ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + return categories.map((c) => this.mapCategoryToDomain(c)); + } + + async deleteCategory(id: string): Promise { + await this.prisma.tagCategory.delete({ where: { id } }); + } + + // ===================== + // 标签定义 CRUD + // ===================== + + async saveTag(tag: UserTagEntity): Promise { + // 处理 JSON 字段的 null 值 + const enumValuesData = tag.enumValues === null + ? Prisma.JsonNull + : tag.enumValues ?? Prisma.JsonNull; + + const saved = await this.prisma.userTag.upsert({ + where: { id: tag.id }, + create: { + id: tag.id, + categoryId: tag.categoryId, + code: tag.code, + name: tag.name, + description: tag.description, + color: tag.color, + type: tag.type as PrismaTagType, + valueType: tag.valueType as PrismaTagValueType, + enumValues: enumValuesData, + ruleId: tag.ruleId, + isAdvertisable: tag.isAdvertisable, + estimatedUsers: tag.estimatedUsers, + isEnabled: tag.isEnabled, + sortOrder: tag.sortOrder, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, + }, + update: { + categoryId: tag.categoryId, + name: tag.name, + description: tag.description, + color: tag.color, + type: tag.type as PrismaTagType, + valueType: tag.valueType as PrismaTagValueType, + enumValues: enumValuesData, + ruleId: tag.ruleId, + isAdvertisable: tag.isAdvertisable, + estimatedUsers: tag.estimatedUsers, + isEnabled: tag.isEnabled, + sortOrder: tag.sortOrder, + updatedAt: tag.updatedAt, + }, + }); + return this.mapTagToDomain(saved); + } + + async findTagById(id: string): Promise { + const tag = await this.prisma.userTag.findUnique({ where: { id } }); + return tag ? this.mapTagToDomain(tag) : null; + } + + async findTagByCode(code: string): Promise { + const tag = await this.prisma.userTag.findUnique({ where: { code } }); + return tag ? this.mapTagToDomain(tag) : null; + } + + async findAllTags(params?: { + categoryId?: string; + type?: TagType; + isEnabled?: boolean; + isAdvertisable?: boolean; + limit?: number; + offset?: number; + }): Promise { + const tags = await this.prisma.userTag.findMany({ + where: { + ...(params?.categoryId && { categoryId: params.categoryId }), + ...(params?.type && { type: params.type as PrismaTagType }), + ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), + ...(params?.isAdvertisable !== undefined && { isAdvertisable: params.isAdvertisable }), + }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + take: params?.limit ?? 100, + skip: params?.offset ?? 0, + }); + return tags.map((t) => this.mapTagToDomain(t)); + } + + async findEnabledTags(): Promise { + return this.findAllTags({ isEnabled: true }); + } + + async findAutoTags(): Promise { + const tags = await this.prisma.userTag.findMany({ + where: { + type: 'AUTO', + isEnabled: true, + ruleId: { not: null }, + }, + orderBy: { sortOrder: 'asc' }, + }); + return tags.map((t) => this.mapTagToDomain(t)); + } + + async findAdvertisableTags(): Promise { + return this.findAllTags({ isEnabled: true, isAdvertisable: true }); + } + + async countTags(params?: { + categoryId?: string; + type?: TagType; + isEnabled?: boolean; + }): Promise { + return this.prisma.userTag.count({ + where: { + ...(params?.categoryId && { categoryId: params.categoryId }), + ...(params?.type && { type: params.type as PrismaTagType }), + ...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }), + }, + }); + } + + async deleteTag(id: string): Promise { + await this.prisma.userTag.delete({ where: { id } }); + } + + async updateEstimatedUsers(tagId: string, count: number): Promise { + await this.prisma.userTag.update({ + where: { id: tagId }, + data: { estimatedUsers: count }, + }); + } + + // ===================== + // 用户-标签关联 + // ===================== + + async saveAssignment(assignment: UserTagAssignmentEntity): Promise { + const saved = await this.prisma.userTagAssignment.upsert({ + where: { + accountSequence_tagId: { + accountSequence: assignment.accountSequence, + tagId: assignment.tagId, + }, + }, + create: { + id: assignment.id, + accountSequence: assignment.accountSequence, + tagId: assignment.tagId, + value: assignment.value, + assignedAt: assignment.assignedAt, + assignedBy: assignment.assignedBy, + expiresAt: assignment.expiresAt, + source: assignment.source, + }, + update: { + value: assignment.value, + assignedAt: assignment.assignedAt, + assignedBy: assignment.assignedBy, + expiresAt: assignment.expiresAt, + source: assignment.source, + }, + }); + return this.mapAssignmentToDomain(saved); + } + + async saveAssignments(assignments: UserTagAssignmentEntity[]): Promise { + if (assignments.length === 0) return; + + await this.prisma.$transaction( + assignments.map((a) => + this.prisma.userTagAssignment.upsert({ + where: { + accountSequence_tagId: { + accountSequence: a.accountSequence, + tagId: a.tagId, + }, + }, + create: { + id: a.id, + accountSequence: a.accountSequence, + tagId: a.tagId, + value: a.value, + assignedAt: a.assignedAt, + assignedBy: a.assignedBy, + expiresAt: a.expiresAt, + source: a.source, + }, + update: { + value: a.value, + assignedAt: a.assignedAt, + assignedBy: a.assignedBy, + expiresAt: a.expiresAt, + source: a.source, + }, + }), + ), + ); + } + + async findUserTags(accountSequence: string): Promise { + const now = new Date(); + const assignments = await this.prisma.userTagAssignment.findMany({ + where: { + accountSequence, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + include: { tag: true }, + orderBy: { assignedAt: 'desc' }, + }); + + return assignments + .filter((a) => a.tag.isEnabled) + .map((a) => ({ + tag: this.mapTagToDomain(a.tag), + assignment: this.mapAssignmentToDomain(a), + })); + } + + async findUserTagIds(accountSequence: string): Promise { + const now = new Date(); + const assignments = await this.prisma.userTagAssignment.findMany({ + where: { + accountSequence, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + tag: { isEnabled: true }, + }, + select: { tagId: true }, + }); + return assignments.map((a) => a.tagId); + } + + async findUsersByTagId( + tagId: string, + params?: { value?: string; limit?: number; offset?: number }, + ): Promise { + const now = new Date(); + const assignments = await this.prisma.userTagAssignment.findMany({ + where: { + tagId, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + ...(params?.value !== undefined && { value: params.value }), + }, + orderBy: { assignedAt: 'desc' }, + take: params?.limit ?? 100, + skip: params?.offset ?? 0, + }); + return assignments.map((a) => this.mapAssignmentToDomain(a)); + } + + async findUsersByTagCode( + tagCode: string, + params?: { value?: string; limit?: number; offset?: number }, + ): Promise { + const now = new Date(); + const assignments = await this.prisma.userTagAssignment.findMany({ + where: { + tag: { code: tagCode }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + ...(params?.value !== undefined && { value: params.value }), + }, + orderBy: { assignedAt: 'desc' }, + take: params?.limit ?? 100, + skip: params?.offset ?? 0, + }); + return assignments.map((a) => this.mapAssignmentToDomain(a)); + } + + async countUsersByTagId(tagId: string, value?: string): Promise { + const now = new Date(); + return this.prisma.userTagAssignment.count({ + where: { + tagId, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + ...(value !== undefined && { value }), + }, + }); + } + + async hasTag(accountSequence: string, tagId: string): Promise { + const now = new Date(); + const count = await this.prisma.userTagAssignment.count({ + where: { + accountSequence, + tagId, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + tag: { isEnabled: true }, + }, + }); + return count > 0; + } + + async hasAnyTag(accountSequence: string, tagIds: string[]): Promise { + if (tagIds.length === 0) return false; + const now = new Date(); + const count = await this.prisma.userTagAssignment.count({ + where: { + accountSequence, + tagId: { in: tagIds }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + tag: { isEnabled: true }, + }, + }); + return count > 0; + } + + async hasAllTags(accountSequence: string, tagIds: string[]): Promise { + if (tagIds.length === 0) return true; + const now = new Date(); + const count = await this.prisma.userTagAssignment.count({ + where: { + accountSequence, + tagId: { in: tagIds }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + tag: { isEnabled: true }, + }, + }); + return count === tagIds.length; + } + + async getAssignment( + accountSequence: string, + tagId: string, + ): Promise { + const assignment = await this.prisma.userTagAssignment.findUnique({ + where: { + accountSequence_tagId: { accountSequence, tagId }, + }, + }); + return assignment ? this.mapAssignmentToDomain(assignment) : null; + } + + async deleteAssignment(accountSequence: string, tagId: string): Promise { + await this.prisma.userTagAssignment.deleteMany({ + where: { accountSequence, tagId }, + }); + } + + async deleteAutoAssignments(accountSequence: string): Promise { + await this.prisma.userTagAssignment.deleteMany({ + where: { + accountSequence, + source: { startsWith: 'rule:' }, + }, + }); + } + + async deleteAssignmentsByTagId(tagId: string): Promise { + await this.prisma.userTagAssignment.deleteMany({ + where: { tagId }, + }); + } + + async deleteAssignmentsBySource(source: string): Promise { + const result = await this.prisma.userTagAssignment.deleteMany({ + where: { source }, + }); + return result.count; + } + + async cleanExpiredAssignments(): Promise { + const now = new Date(); + const result = await this.prisma.userTagAssignment.deleteMany({ + where: { + expiresAt: { lt: now }, + }, + }); + if (result.count > 0) { + this.logger.log(`Cleaned ${result.count} expired tag assignments`); + } + return result.count; + } + + // ===================== + // 标签变更日志 + // ===================== + + async saveLog(log: UserTagLogEntity): Promise { + await this.prisma.userTagLog.create({ + data: { + id: log.id, + accountSequence: log.accountSequence, + tagCode: log.tagCode, + action: log.action as PrismaTagAction, + oldValue: log.oldValue, + newValue: log.newValue, + reason: log.reason, + operatorId: log.operatorId, + createdAt: log.createdAt, + }, + }); + } + + async saveLogs(logs: UserTagLogEntity[]): Promise { + if (logs.length === 0) return; + await this.prisma.userTagLog.createMany({ + data: logs.map((log) => ({ + id: log.id, + accountSequence: log.accountSequence, + tagCode: log.tagCode, + action: log.action as PrismaTagAction, + oldValue: log.oldValue, + newValue: log.newValue, + reason: log.reason, + operatorId: log.operatorId, + createdAt: log.createdAt, + })), + }); + } + + async findLogs(params: { + accountSequence?: string; + tagCode?: string; + action?: TagAction; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; + }): Promise { + const logs = await this.prisma.userTagLog.findMany({ + where: { + ...(params.accountSequence && { accountSequence: params.accountSequence }), + ...(params.tagCode && { tagCode: params.tagCode }), + ...(params.action && { action: params.action as PrismaTagAction }), + ...(params.startDate || params.endDate + ? { + createdAt: { + ...(params.startDate && { gte: params.startDate }), + ...(params.endDate && { lte: params.endDate }), + }, + } + : {}), + }, + orderBy: { createdAt: 'desc' }, + take: params.limit ?? 100, + skip: params.offset ?? 0, + }); + return logs.map((log) => this.mapLogToDomain(log)); + } + + async countLogs(params: { + accountSequence?: string; + tagCode?: string; + action?: TagAction; + startDate?: Date; + endDate?: Date; + }): Promise { + return this.prisma.userTagLog.count({ + where: { + ...(params.accountSequence && { accountSequence: params.accountSequence }), + ...(params.tagCode && { tagCode: params.tagCode }), + ...(params.action && { action: params.action as PrismaTagAction }), + ...(params.startDate || params.endDate + ? { + createdAt: { + ...(params.startDate && { gte: params.startDate }), + ...(params.endDate && { lte: params.endDate }), + }, + } + : {}), + }, + }); + } + + // ===================== + // Private Methods + // ===================== + + private mapCategoryToDomain(prisma: { + id: string; + code: string; + name: string; + description: string | null; + sortOrder: number; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; + }): TagCategoryEntity { + return new TagCategoryEntity( + prisma.id, + prisma.code, + prisma.name, + prisma.description, + prisma.sortOrder, + prisma.isEnabled, + prisma.createdAt, + prisma.updatedAt, + ); + } + + private mapTagToDomain(prisma: { + id: string; + categoryId: string | null; + code: string; + name: string; + description: string | null; + color: string | null; + type: PrismaTagType; + valueType: PrismaTagValueType; + enumValues: unknown; + ruleId: string | null; + isAdvertisable: boolean; + estimatedUsers: number | null; + isEnabled: boolean; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + }): UserTagEntity { + return new UserTagEntity( + prisma.id, + prisma.categoryId, + prisma.code, + prisma.name, + prisma.description, + prisma.color, + prisma.type as TagType, + prisma.valueType as TagValueType, + prisma.enumValues as string[] | null, + prisma.ruleId, + prisma.isAdvertisable, + prisma.estimatedUsers, + prisma.isEnabled, + prisma.sortOrder, + prisma.createdAt, + prisma.updatedAt, + ); + } + + private mapAssignmentToDomain(prisma: { + id: string; + accountSequence: string; + tagId: string; + value: string | null; + assignedAt: Date; + assignedBy: string | null; + expiresAt: Date | null; + source: string | null; + }): UserTagAssignmentEntity { + return new UserTagAssignmentEntity( + prisma.id, + prisma.accountSequence, + prisma.tagId, + prisma.value, + prisma.assignedAt, + prisma.assignedBy, + prisma.expiresAt, + prisma.source, + ); + } + + private mapLogToDomain(prisma: { + id: string; + accountSequence: string; + tagCode: string; + action: PrismaTagAction; + oldValue: string | null; + newValue: string | null; + reason: string | null; + operatorId: string | null; + createdAt: Date; + }): UserTagLogEntity { + return new UserTagLogEntity( + prisma.id, + prisma.accountSequence, + prisma.tagCode, + prisma.action as TagAction, + prisma.oldValue, + prisma.newValue, + prisma.reason, + prisma.operatorId, + prisma.createdAt, + ); + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss b/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss index fc6207a8..acc3eac9 100644 --- a/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss @@ -22,6 +22,38 @@ gap: 12px; } + // Tab 导航 + &__tabs { + display: flex; + gap: 4px; + background: #f3f4f6; + padding: 4px; + border-radius: 10px; + width: fit-content; + } + + &__tab { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: $text-secondary; + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: $text-primary; + } + + &--active { + background: white; + color: $primary-color; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + } + &__card { background: $card-background; border-radius: 12px; @@ -265,4 +297,67 @@ justify-content: flex-end; gap: 12px; } + + // 标签选择器 + &__tagSelector { + border: 1px solid $border-color; + border-radius: 8px; + padding: 12px; + max-height: 200px; + overflow-y: auto; + } + + &__tagList { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__tagOption { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: #f3f4f6; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + + input { + width: 16px; + height: 16px; + cursor: pointer; + } + + &:hover { + background: #e5e7eb; + } + + &--selected { + background: #dbeafe; + color: #1d4ed8; + } + } + + &__tagColor { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; + } + + // 用户ID输入 + &__userIdsInput { + font-family: monospace; + font-size: 13px; + line-height: 1.6; + min-height: 80px; + } + + &__targetHint { + font-size: 12px; + color: $text-disabled; + margin-top: 4px; + } } diff --git a/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx index a728bd06..a410a235 100644 --- a/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect } from 'react'; import { Modal, toast, Button } from '@/components/common'; import { PageContainer } from '@/components/layout'; +import { UserTagsTab, AudienceSegmentsTab } from '@/components/features/notifications'; import { cn } from '@/utils/helpers'; import { formatDateTime } from '@/utils/formatters'; import { @@ -15,8 +16,18 @@ import { NOTIFICATION_PRIORITY_OPTIONS, TARGET_TYPE_OPTIONS, } from '@/services/notificationService'; +import { userTagService, UserTag } from '@/services/userTagService'; import styles from './notifications.module.scss'; +// Tab 类型 +type TabType = 'notifications' | 'tags' | 'segments'; + +const TABS = [ + { key: 'notifications' as TabType, label: '通知列表' }, + { key: 'tags' as TabType, label: '用户标签' }, + { key: 'segments' as TabType, label: '人群包' }, +]; + // 获取类型标签样式 const getTypeStyle = (type: NotificationType) => { const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type); @@ -45,11 +56,18 @@ const getTargetLabel = (target: string) => { * 通知管理页面 */ export default function NotificationsPage() { + // Tab 状态 + const [activeTab, setActiveTab] = useState('notifications'); + + // 通知数据状态 const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [typeFilter, setTypeFilter] = useState(''); + // 可用标签列表(用于定向选择) + const [availableTags, setAvailableTags] = useState([]); + // 弹窗状态 const [showModal, setShowModal] = useState(false); const [editingNotification, setEditingNotification] = useState(null); @@ -62,6 +80,8 @@ export default function NotificationsPage() { type: 'SYSTEM' as NotificationType, priority: 'NORMAL' as NotificationPriority, targetType: 'ALL' as TargetType, + selectedTagIds: [] as string[], + userIds: '', imageUrl: '', linkUrl: '', publishedAt: '', @@ -85,9 +105,22 @@ export default function NotificationsPage() { } }, [typeFilter]); + // 加载可用标签 + const loadTags = useCallback(async () => { + try { + const tags = await userTagService.getTags({ isAdvertisable: true, isEnabled: true }); + setAvailableTags(tags); + } catch (err) { + console.error('Failed to load tags:', err); + } + }, []); + useEffect(() => { - loadNotifications(); - }, [loadNotifications]); + if (activeTab === 'notifications') { + loadNotifications(); + loadTags(); + } + }, [activeTab, loadNotifications, loadTags]); // 打开创建弹窗 const handleCreate = () => { @@ -98,6 +131,8 @@ export default function NotificationsPage() { type: 'SYSTEM', priority: 'NORMAL', targetType: 'ALL', + selectedTagIds: [], + userIds: '', imageUrl: '', linkUrl: '', publishedAt: '', @@ -115,6 +150,8 @@ export default function NotificationsPage() { type: notification.type, priority: notification.priority, targetType: notification.targetType, + selectedTagIds: notification.targetConfig?.tagIds || [], + userIds: notification.targetConfig?.accountSequences?.join('\n') || '', imageUrl: notification.imageUrl || '', linkUrl: notification.linkUrl || '', publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '', @@ -140,6 +177,27 @@ export default function NotificationsPage() { return; } + // 构建目标配置 + let targetConfig: { tagIds?: string[]; accountSequences?: string[] } | undefined; + + if (formData.targetType === 'BY_TAG') { + if (formData.selectedTagIds.length === 0) { + toast.error('请至少选择一个标签'); + return; + } + targetConfig = { tagIds: formData.selectedTagIds }; + } else if (formData.targetType === 'SPECIFIC') { + const userIds = formData.userIds + .split(/[\n,]/) + .map(id => id.trim()) + .filter(Boolean); + if (userIds.length === 0) { + toast.error('请输入用户ID'); + return; + } + targetConfig = { accountSequences: userIds }; + } + try { const payload = { title: formData.title.trim(), @@ -147,6 +205,7 @@ export default function NotificationsPage() { type: formData.type, priority: formData.priority, targetType: formData.targetType, + targetConfig, imageUrl: formData.imageUrl.trim() || undefined, linkUrl: formData.linkUrl.trim() || undefined, publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined, @@ -168,6 +227,16 @@ export default function NotificationsPage() { } }; + // 切换标签选择 + const toggleTagSelection = (tagId: string) => { + setFormData(prev => ({ + ...prev, + selectedTagIds: prev.selectedTagIds.includes(tagId) + ? prev.selectedTagIds.filter(id => id !== tagId) + : [...prev.selectedTagIds, tagId], + })); + }; + // 切换启用状态 const handleToggle = async (notification: NotificationItem) => { try { @@ -194,118 +263,156 @@ export default function NotificationsPage() { return (
- {/* 页面标题和操作按钮 */} + {/* 页面标题 */}
-

通知管理

-
- -
+

通知与用户画像

- {/* 主内容卡片 */} -
- {/* 筛选区域 */} -
- - -
+ {tab.label} + + ))} +
- {/* 通知列表 */} -
- {loading ? ( -
加载中...
- ) : error ? ( -
- {error} + {/* Tab 内容 */} + {activeTab === 'notifications' && ( + <> + {/* 主内容卡片 */} +
+ {/* 筛选区域 */} +
+ +
+
- ) : notifications.length === 0 ? ( -
- 暂无通知,点击"新建通知"创建第一条通知 -
- ) : ( - notifications.map((notification) => ( -
-
-
- - {getTypeLabel(notification.type)} - - - {getPriorityLabel(notification.priority)} - - - {getTargetLabel(notification.targetType)} - - {!notification.isEnabled && ( - 已禁用 + + {/* 通知列表 */} +
+ {loading ? ( +
加载中...
+ ) : error ? ( +
+ {error} + +
+ ) : notifications.length === 0 ? ( +
+ 暂无通知,点击"新建通知"创建第一条通知 +
+ ) : ( + notifications.map((notification) => ( +
+
+
+ + {getTypeLabel(notification.type)} + + + {getPriorityLabel(notification.priority)} + + + {getTargetLabel(notification.targetType)} + {notification.targetConfig?.tagIds && notification.targetConfig.tagIds.length > 0 && ( + <> ({notification.targetConfig.tagIds.length}个标签) + )} + {notification.targetConfig?.accountSequences && notification.targetConfig.accountSequences.length > 0 && ( + <> ({notification.targetConfig.accountSequences.length}个用户) + )} + + {!notification.isEnabled && ( + 已禁用 + )} +
+
+ + + +
+
+

{notification.title}

+

{notification.content}

+
+ 创建时间: {formatDateTime(notification.createdAt)} + {notification.publishedAt && ( + 发布时间: {formatDateTime(notification.publishedAt)} + )} + {notification.expiresAt && ( + 过期时间: {formatDateTime(notification.expiresAt)} + )} +
-
- - - -
-
-

{notification.title}

-

{notification.content}

-
- 创建时间: {formatDateTime(notification.createdAt)} - {notification.publishedAt && ( - 发布时间: {formatDateTime(notification.publishedAt)} - )} - {notification.expiresAt && ( - 过期时间: {formatDateTime(notification.expiresAt)} - )} -
-
- )) - )} + )) + )} +
+
+ + )} + + {activeTab === 'tags' && ( +
+
-
+ )} + + {activeTab === 'segments' && ( +
+ +
+ )} {/* 创建/编辑弹窗 */}
} - width={640} + width={720} >
@@ -390,6 +497,63 @@ export default function NotificationsPage() {
+ {/* 按标签筛选 */} + {formData.targetType === 'BY_TAG' && ( +
+ +
+ {availableTags.length === 0 ? ( +
+ 暂无可用标签,请先在"用户标签"中创建 +
+ ) : ( +
+ {availableTags.map(tag => ( + + ))} +
+ )} +
+ + 已选择 {formData.selectedTagIds.length} 个标签 + +
+ )} + + {/* 指定用户 */} + {formData.targetType === 'SPECIFIC' && ( +
+ +