feat(user-profile): 实现用户画像系统和通知定向功能
后端 (admin-service): - 新增用户标签系统:标签分类、标签定义、用户标签分配 - 新增分类规则引擎:支持自动打标规则 - 新增人群包管理:支持复杂条件组合筛选用户 - 增强通知系统:支持按标签、按人群包、指定用户定向发送 - 新增自动标签同步定时任务 - Prisma Schema 扩展支持新数据模型 前端 (admin-web): - 通知管理页面新增 Tab 切换:通知列表、用户标签、人群包 - 用户标签管理:分类管理、标签 CRUD、颜色/类型配置 - 人群包管理:条件组编辑器、逻辑运算符配置 - 通知编辑器:支持按标签筛选和指定用户定向 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
934323e4c6
commit
b5e45c4532
|
|
@ -15,6 +15,7 @@
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
|
|
@ -1888,6 +1889,33 @@
|
||||||
"@nestjs/core": "^10.0.0"
|
"@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": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "10.2.3",
|
"version": "10.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||||
|
|
@ -2451,6 +2479,12 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
|
|
@ -4244,6 +4278,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -7314,6 +7358,15 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.0",
|
"@nestjs/passport": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ model Notification {
|
||||||
type NotificationType // 通知类型
|
type NotificationType // 通知类型
|
||||||
priority NotificationPriority @default(NORMAL) // 优先级
|
priority NotificationPriority @default(NORMAL) // 优先级
|
||||||
targetType TargetType @default(ALL) // 目标用户类型
|
targetType TargetType @default(ALL) // 目标用户类型
|
||||||
|
targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑
|
||||||
imageUrl String? // 可选的图片URL
|
imageUrl String? // 可选的图片URL
|
||||||
linkUrl String? // 可选的跳转链接
|
linkUrl String? // 可选的跳转链接
|
||||||
isEnabled Boolean @default(true) // 是否启用
|
isEnabled Boolean @default(true) // 是否启用
|
||||||
|
|
@ -65,11 +66,14 @@ model Notification {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdBy String // 创建人ID
|
createdBy String // 创建人ID
|
||||||
|
|
||||||
// 用户已读记录
|
// 关联
|
||||||
readRecords NotificationRead[]
|
readRecords NotificationRead[]
|
||||||
|
targetTags NotificationTagTarget[] // BY_TAG 时使用
|
||||||
|
targetUsers NotificationUserTarget[] // SPECIFIC 时使用
|
||||||
|
|
||||||
@@index([isEnabled, publishedAt])
|
@@index([isEnabled, publishedAt])
|
||||||
@@index([type])
|
@@index([type])
|
||||||
|
@@index([targetType])
|
||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,9 +110,312 @@ enum NotificationPriority {
|
||||||
|
|
||||||
/// 目标用户类型
|
/// 目标用户类型
|
||||||
enum TargetType {
|
enum TargetType {
|
||||||
ALL // 所有用户
|
ALL // 所有用户
|
||||||
NEW_USER // 新用户
|
BY_TAG // 按标签匹配
|
||||||
VIP // VIP用户
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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<SegmentResponseDto> {
|
||||||
|
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<SegmentListResponseDto> {
|
||||||
|
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<SegmentResponseDto> {
|
||||||
|
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<SegmentResponseDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.segmentService.deleteSegment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用户是否匹配人群包
|
||||||
|
*/
|
||||||
|
@Post(':id/test')
|
||||||
|
async testSegment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: TestSegmentDto,
|
||||||
|
): Promise<SegmentTestResultDto> {
|
||||||
|
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<SegmentEstimateResultDto> {
|
||||||
|
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<SegmentEstimateResultDto> {
|
||||||
|
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<SegmentUsersResponseDto> {
|
||||||
|
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<UserSegmentsResponseDto> {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<RuleResponseDto> {
|
||||||
|
// 验证条件格式
|
||||||
|
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<RuleListResponseDto> {
|
||||||
|
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<RuleResponseDto> {
|
||||||
|
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<RuleResponseDto> {
|
||||||
|
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<void> {
|
||||||
|
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<RuleResponseDto> {
|
||||||
|
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<RuleResponseDto> {
|
||||||
|
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<RuleTestResultDto> {
|
||||||
|
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<RuleEstimateResultDto> {
|
||||||
|
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<RuleEstimateResultDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,13 +10,18 @@ import {
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_REPOSITORY,
|
NOTIFICATION_REPOSITORY,
|
||||||
NotificationRepository,
|
NotificationRepository,
|
||||||
} from '../../domain/repositories/notification.repository';
|
} from '../../domain/repositories/notification.repository';
|
||||||
import { NotificationEntity } from '../../domain/entities/notification.entity';
|
import {
|
||||||
|
NotificationEntity,
|
||||||
|
NotificationTarget,
|
||||||
|
TargetType,
|
||||||
|
} from '../../domain/entities/notification.entity';
|
||||||
import {
|
import {
|
||||||
CreateNotificationDto,
|
CreateNotificationDto,
|
||||||
UpdateNotificationDto,
|
UpdateNotificationDto,
|
||||||
|
|
@ -46,13 +51,27 @@ export class AdminNotificationController {
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateNotificationDto): Promise<NotificationResponseDto> {
|
async create(@Body() dto: CreateNotificationDto): Promise<NotificationResponseDto> {
|
||||||
|
// 构建目标配置
|
||||||
|
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({
|
const notification = NotificationEntity.create({
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
content: dto.content,
|
content: dto.content,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
priority: dto.priority,
|
priority: dto.priority,
|
||||||
targetType: dto.targetType,
|
targetType,
|
||||||
|
targetConfig,
|
||||||
imageUrl: dto.imageUrl,
|
imageUrl: dto.imageUrl,
|
||||||
linkUrl: dto.linkUrl,
|
linkUrl: dto.linkUrl,
|
||||||
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
||||||
|
|
@ -71,7 +90,7 @@ export class AdminNotificationController {
|
||||||
async findOne(@Param('id') id: string): Promise<NotificationResponseDto> {
|
async findOne(@Param('id') id: string): Promise<NotificationResponseDto> {
|
||||||
const notification = await this.notificationRepo.findById(id);
|
const notification = await this.notificationRepo.findById(id);
|
||||||
if (!notification) {
|
if (!notification) {
|
||||||
throw new Error('Notification not found');
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
}
|
||||||
return NotificationResponseDto.fromEntity(notification);
|
return NotificationResponseDto.fromEntity(notification);
|
||||||
}
|
}
|
||||||
|
|
@ -99,33 +118,48 @@ export class AdminNotificationController {
|
||||||
): Promise<NotificationResponseDto> {
|
): Promise<NotificationResponseDto> {
|
||||||
const existing = await this.notificationRepo.findById(id);
|
const existing = await this.notificationRepo.findById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new Error('Notification not found');
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = new NotificationEntity(
|
// 构建目标配置
|
||||||
existing.id,
|
let targetConfig: NotificationTarget | null | undefined = undefined;
|
||||||
dto.title ?? existing.title,
|
if (dto.targetConfig !== undefined || dto.targetType !== undefined) {
|
||||||
dto.content ?? existing.content,
|
const targetType = dto.targetType ?? existing.targetType;
|
||||||
dto.type ?? existing.type,
|
if (dto.targetConfig || targetType !== TargetType.ALL) {
|
||||||
dto.priority ?? existing.priority,
|
targetConfig = {
|
||||||
dto.targetType ?? existing.targetType,
|
type: targetType,
|
||||||
dto.imageUrl !== undefined ? dto.imageUrl : existing.imageUrl,
|
tagIds: dto.targetConfig?.tagIds ?? existing.targetConfig?.tagIds,
|
||||||
dto.linkUrl !== undefined ? dto.linkUrl : existing.linkUrl,
|
segmentId: dto.targetConfig?.segmentId ?? existing.targetConfig?.segmentId,
|
||||||
dto.isEnabled ?? existing.isEnabled,
|
accountSequences:
|
||||||
dto.publishedAt !== undefined
|
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
|
? dto.publishedAt
|
||||||
? new Date(dto.publishedAt)
|
? new Date(dto.publishedAt)
|
||||||
: null
|
: null
|
||||||
: existing.publishedAt,
|
: undefined,
|
||||||
dto.expiresAt !== undefined
|
expiresAt: dto.expiresAt !== undefined
|
||||||
? dto.expiresAt
|
? dto.expiresAt
|
||||||
? new Date(dto.expiresAt)
|
? new Date(dto.expiresAt)
|
||||||
: null
|
: null
|
||||||
: existing.expiresAt,
|
: undefined,
|
||||||
existing.createdAt,
|
});
|
||||||
new Date(),
|
|
||||||
existing.createdBy,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saved = await this.notificationRepo.save(updated);
|
const saved = await this.notificationRepo.save(updated);
|
||||||
return NotificationResponseDto.fromEntity(saved);
|
return NotificationResponseDto.fromEntity(saved);
|
||||||
|
|
|
||||||
|
|
@ -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<TagCategoryResponseDto> {
|
||||||
|
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<TagCategoryResponseDto[]> {
|
||||||
|
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<TagCategoryResponseDto> {
|
||||||
|
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<TagCategoryResponseDto> {
|
||||||
|
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<void> {
|
||||||
|
await this.tagRepository.deleteCategory(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签定义管理
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建标签
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async createTag(@Body() dto: CreateTagDto): Promise<TagResponseDto> {
|
||||||
|
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<TagListResponseDto> {
|
||||||
|
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<TagResponseDto> {
|
||||||
|
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<TagResponseDto> {
|
||||||
|
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<void> {
|
||||||
|
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<TagResponseDto> {
|
||||||
|
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<TagResponseDto> {
|
||||||
|
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<BatchOperationResultDto> {
|
||||||
|
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<UserTagListResponseDto> {
|
||||||
|
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<TagUserListResponseDto> {
|
||||||
|
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<TagSyncResultResponseDto> {
|
||||||
|
return this.taggingService.syncUserAutoTags(dto.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步自动标签
|
||||||
|
*/
|
||||||
|
@Post('batch-sync')
|
||||||
|
async batchSyncTags(
|
||||||
|
@Body() dto: BatchSyncAutoTagsDto,
|
||||||
|
): Promise<BatchSyncResultResponseDto> {
|
||||||
|
return this.taggingService.batchSyncAutoTags(dto.accountSequences);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步所有用户的自动标签
|
||||||
|
*/
|
||||||
|
@Post('sync-all')
|
||||||
|
async syncAllTags(): Promise<BatchSyncResultResponseDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,40 @@
|
||||||
import { IsString, IsOptional, IsEnum, IsBoolean, IsDateString, IsInt, Min, Max } from 'class-validator';
|
import {
|
||||||
import { NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity';
|
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)
|
@IsEnum(TargetType)
|
||||||
targetType?: TargetType;
|
targetType?: TargetType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => TargetConfigDto)
|
||||||
|
targetConfig?: TargetConfigDto;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
|
@ -63,6 +103,11 @@ export class UpdateNotificationDto {
|
||||||
@IsEnum(TargetType)
|
@IsEnum(TargetType)
|
||||||
targetType?: TargetType;
|
targetType?: TargetType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => TargetConfigDto)
|
||||||
|
targetConfig?: TargetConfigDto;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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';
|
import { NotificationWithReadStatus } from '../../../domain/repositories/notification.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标配置响应
|
||||||
|
*/
|
||||||
|
export interface TargetConfigResponseDto {
|
||||||
|
type: TargetType;
|
||||||
|
tagIds?: string[];
|
||||||
|
segmentId?: string;
|
||||||
|
accountSequences?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知响应DTO
|
* 通知响应DTO
|
||||||
*/
|
*/
|
||||||
|
|
@ -11,6 +27,7 @@ export class NotificationResponseDto {
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
priority: NotificationPriority;
|
priority: NotificationPriority;
|
||||||
targetType: TargetType;
|
targetType: TargetType;
|
||||||
|
targetConfig: TargetConfigResponseDto | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
linkUrl: string | null;
|
linkUrl: string | null;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
|
|
@ -26,6 +43,7 @@ export class NotificationResponseDto {
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
priority: entity.priority,
|
priority: entity.priority,
|
||||||
targetType: entity.targetType,
|
targetType: entity.targetType,
|
||||||
|
targetConfig: entity.targetConfig,
|
||||||
imageUrl: entity.imageUrl,
|
imageUrl: entity.imageUrl,
|
||||||
linkUrl: entity.linkUrl,
|
linkUrl: entity.linkUrl,
|
||||||
isEnabled: entity.isEnabled,
|
isEnabled: entity.isEnabled,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { configurations } from './config';
|
import { configurations } from './config';
|
||||||
import { PrismaService } from './infrastructure/persistence/prisma/prisma.service';
|
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 { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl';
|
||||||
import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository';
|
import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository';
|
||||||
import { AdminSystemConfigController, PublicSystemConfigController } from './api/controllers/system-config.controller';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -47,6 +62,8 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
||||||
rootPath: join(process.cwd(), process.env.UPLOAD_DIR || 'uploads'),
|
rootPath: join(process.cwd(), process.env.UPLOAD_DIR || 'uploads'),
|
||||||
serveRoot: '/uploads',
|
serveRoot: '/uploads',
|
||||||
}),
|
}),
|
||||||
|
// Schedule module for cron jobs
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
VersionController,
|
VersionController,
|
||||||
|
|
@ -58,6 +75,10 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
||||||
UserController,
|
UserController,
|
||||||
AdminSystemConfigController,
|
AdminSystemConfigController,
|
||||||
PublicSystemConfigController,
|
PublicSystemConfigController,
|
||||||
|
// User Profile System Controllers
|
||||||
|
UserTagController,
|
||||||
|
ClassificationRuleController,
|
||||||
|
AudienceSegmentController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -95,6 +116,24 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
||||||
provide: SYSTEM_CONFIG_REPOSITORY,
|
provide: SYSTEM_CONFIG_REPOSITORY,
|
||||||
useClass: SystemConfigRepositoryImpl,
|
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 {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -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<AudienceSegmentEntity> {
|
||||||
|
// 验证条件格式
|
||||||
|
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<AudienceSegmentEntity> {
|
||||||
|
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<void> {
|
||||||
|
await this.segmentRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人群包详情
|
||||||
|
*/
|
||||||
|
async getSegment(id: string): Promise<AudienceSegmentEntity | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<SegmentMatchResult[]> {
|
||||||
|
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<number> {
|
||||||
|
const { total } = await this.findMatchingUsers(segmentId);
|
||||||
|
await this.segmentRepository.updateEstimatedUsers(segmentId, total);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新所有人群包的预估用户数
|
||||||
|
*/
|
||||||
|
async recalculateAllEstimatedUsers(): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<UserRuleEvaluationResult> {
|
||||||
|
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<Map<string, UserRuleEvaluationResult>> {
|
||||||
|
const results = new Map<string, UserRuleEvaluationResult>();
|
||||||
|
|
||||||
|
// 获取所有已关联标签的启用规则
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<TagSyncResult> {
|
||||||
|
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<BatchSyncResult> {
|
||||||
|
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<BatchSyncResult> {
|
||||||
|
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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
return this.tagRepository.hasTag(accountSequence, tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有任一标签
|
||||||
|
*/
|
||||||
|
async userHasAnyTag(
|
||||||
|
accountSequence: string,
|
||||||
|
tagIds: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.tagRepository.hasAnyTag(accountSequence, tagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有所有标签
|
||||||
|
*/
|
||||||
|
async userHasAllTags(
|
||||||
|
accountSequence: string,
|
||||||
|
tagIds: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.tagRepository.hasAllTags(accountSequence, tagIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 type: NotificationType,
|
||||||
public readonly priority: NotificationPriority,
|
public readonly priority: NotificationPriority,
|
||||||
public readonly targetType: TargetType,
|
public readonly targetType: TargetType,
|
||||||
|
public readonly targetConfig: NotificationTarget | null,
|
||||||
public readonly imageUrl: string | null,
|
public readonly imageUrl: string | null,
|
||||||
public readonly linkUrl: string | null,
|
public readonly linkUrl: string | null,
|
||||||
public readonly isEnabled: boolean,
|
public readonly isEnabled: boolean,
|
||||||
|
|
@ -46,6 +57,35 @@ export class NotificationEntity {
|
||||||
return this.expiresAt < new Date();
|
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;
|
type: NotificationType;
|
||||||
priority?: NotificationPriority;
|
priority?: NotificationPriority;
|
||||||
targetType?: TargetType;
|
targetType?: TargetType;
|
||||||
|
targetConfig?: NotificationTarget | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
linkUrl?: string | null;
|
linkUrl?: string | null;
|
||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
|
|
@ -63,13 +104,27 @@ export class NotificationEntity {
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
}): NotificationEntity {
|
}): NotificationEntity {
|
||||||
const now = new Date();
|
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(
|
return new NotificationEntity(
|
||||||
params.id,
|
params.id,
|
||||||
params.title,
|
params.title,
|
||||||
params.content,
|
params.content,
|
||||||
params.type,
|
params.type,
|
||||||
params.priority ?? NotificationPriority.NORMAL,
|
params.priority ?? NotificationPriority.NORMAL,
|
||||||
params.targetType ?? TargetType.ALL,
|
targetType,
|
||||||
|
targetConfig,
|
||||||
params.imageUrl ?? null,
|
params.imageUrl ?? null,
|
||||||
params.linkUrl ?? null,
|
params.linkUrl ?? null,
|
||||||
true,
|
true,
|
||||||
|
|
@ -80,6 +135,41 @@ export class NotificationEntity {
|
||||||
params.createdBy,
|
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 {
|
export enum TargetType {
|
||||||
ALL = 'ALL',
|
ALL = 'ALL', // 所有用户
|
||||||
NEW_USER = 'NEW_USER',
|
BY_TAG = 'BY_TAG', // 按标签筛选
|
||||||
VIP = 'VIP',
|
SPECIFIC = 'SPECIFIC', // 特定用户列表
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown> | 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<string, unknown>): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<AudienceSegmentEntity>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 查找
|
||||||
|
*/
|
||||||
|
findById(id: string): Promise<AudienceSegmentEntity | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找所有人群包
|
||||||
|
*/
|
||||||
|
findAll(params?: {
|
||||||
|
usageType?: SegmentUsageType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<AudienceSegmentEntity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用途类型查找
|
||||||
|
*/
|
||||||
|
findByUsageType(usageType: SegmentUsageType): Promise<AudienceSegmentEntity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计人群包数量
|
||||||
|
*/
|
||||||
|
count(params?: {
|
||||||
|
usageType?: SegmentUsageType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人群包
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预估用户数
|
||||||
|
*/
|
||||||
|
updateEstimatedUsers(id: string, count: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -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<ClassificationRuleEntity>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找规则
|
||||||
|
*/
|
||||||
|
findById(id: string): Promise<ClassificationRuleEntity | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找所有规则
|
||||||
|
*/
|
||||||
|
findAll(params?: {
|
||||||
|
isEnabled?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<ClassificationRuleEntity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找所有启用的规则
|
||||||
|
*/
|
||||||
|
findEnabled(): Promise<ClassificationRuleEntity[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找已关联标签的规则
|
||||||
|
*/
|
||||||
|
findLinkedRules(): Promise<RuleWithTag[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计规则数量
|
||||||
|
*/
|
||||||
|
count(params?: {
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除规则
|
||||||
|
*/
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则及其关联的标签
|
||||||
|
*/
|
||||||
|
export interface RuleWithTag {
|
||||||
|
rule: ClassificationRuleEntity;
|
||||||
|
tagId: string;
|
||||||
|
tagCode: string;
|
||||||
|
}
|
||||||
|
|
@ -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<TagCategoryEntity>;
|
||||||
|
findCategoryById(id: string): Promise<TagCategoryEntity | null>;
|
||||||
|
findCategoryByCode(code: string): Promise<TagCategoryEntity | null>;
|
||||||
|
findAllCategories(params?: { isEnabled?: boolean }): Promise<TagCategoryEntity[]>;
|
||||||
|
deleteCategory(id: string): Promise<void>;
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签定义 CRUD
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
saveTag(tag: UserTagEntity): Promise<UserTagEntity>;
|
||||||
|
findTagById(id: string): Promise<UserTagEntity | null>;
|
||||||
|
findTagByCode(code: string): Promise<UserTagEntity | null>;
|
||||||
|
findAllTags(params?: {
|
||||||
|
categoryId?: string;
|
||||||
|
type?: TagType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
isAdvertisable?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<UserTagEntity[]>;
|
||||||
|
findEnabledTags(): Promise<UserTagEntity[]>;
|
||||||
|
findAutoTags(): Promise<UserTagEntity[]>;
|
||||||
|
findAdvertisableTags(): Promise<UserTagEntity[]>;
|
||||||
|
countTags(params?: {
|
||||||
|
categoryId?: string;
|
||||||
|
type?: TagType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}): Promise<number>;
|
||||||
|
deleteTag(id: string): Promise<void>;
|
||||||
|
updateEstimatedUsers(tagId: string, count: number): Promise<void>;
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 用户-标签关联
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
saveAssignment(assignment: UserTagAssignmentEntity): Promise<UserTagAssignmentEntity>;
|
||||||
|
saveAssignments(assignments: UserTagAssignmentEntity[]): Promise<void>;
|
||||||
|
findUserTags(accountSequence: string): Promise<UserTagWithAssignment[]>;
|
||||||
|
findUserTagIds(accountSequence: string): Promise<string[]>;
|
||||||
|
findUsersByTagId(tagId: string, params?: {
|
||||||
|
value?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<UserTagAssignmentEntity[]>;
|
||||||
|
findUsersByTagCode(tagCode: string, params?: {
|
||||||
|
value?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<UserTagAssignmentEntity[]>;
|
||||||
|
countUsersByTagId(tagId: string, value?: string): Promise<number>;
|
||||||
|
hasTag(accountSequence: string, tagId: string): Promise<boolean>;
|
||||||
|
hasAnyTag(accountSequence: string, tagIds: string[]): Promise<boolean>;
|
||||||
|
hasAllTags(accountSequence: string, tagIds: string[]): Promise<boolean>;
|
||||||
|
getAssignment(accountSequence: string, tagId: string): Promise<UserTagAssignmentEntity | null>;
|
||||||
|
deleteAssignment(accountSequence: string, tagId: string): Promise<void>;
|
||||||
|
deleteAutoAssignments(accountSequence: string): Promise<void>;
|
||||||
|
deleteAssignmentsByTagId(tagId: string): Promise<void>;
|
||||||
|
deleteAssignmentsBySource(source: string): Promise<number>;
|
||||||
|
cleanExpiredAssignments(): Promise<number>;
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签变更日志
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
saveLog(log: UserTagLogEntity): Promise<void>;
|
||||||
|
saveLogs(logs: UserTagLogEntity[]): Promise<void>;
|
||||||
|
findLogs(params: {
|
||||||
|
accountSequence?: string;
|
||||||
|
tagCode?: string;
|
||||||
|
action?: TagAction;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<UserTagLogEntity[]>;
|
||||||
|
countLogs(params: {
|
||||||
|
accountSequence?: string;
|
||||||
|
tagCode?: string;
|
||||||
|
action?: TagAction;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户标签(包含关联信息)
|
||||||
|
*/
|
||||||
|
export interface UserTagWithAssignment {
|
||||||
|
tag: UserTagEntity;
|
||||||
|
assignment: UserTagAssignmentEntity;
|
||||||
|
}
|
||||||
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,18 +10,49 @@ import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
NotificationPriority,
|
NotificationPriority,
|
||||||
TargetType,
|
TargetType,
|
||||||
|
NotificationTarget,
|
||||||
} from '../../../domain/entities/notification.entity';
|
} from '../../../domain/entities/notification.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带关联数据的通知
|
||||||
|
*/
|
||||||
|
export interface NotificationWithTargets extends PrismaNotification {
|
||||||
|
targetTags?: { tagId: string }[];
|
||||||
|
targetUsers?: { accountSequence: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationMapper {
|
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(
|
return new NotificationEntity(
|
||||||
prisma.id,
|
prisma.id,
|
||||||
prisma.title,
|
prisma.title,
|
||||||
prisma.content,
|
prisma.content,
|
||||||
prisma.type as NotificationType,
|
prisma.type as NotificationType,
|
||||||
prisma.priority as NotificationPriority,
|
prisma.priority as NotificationPriority,
|
||||||
prisma.targetType as TargetType,
|
targetType,
|
||||||
|
targetConfig,
|
||||||
prisma.imageUrl,
|
prisma.imageUrl,
|
||||||
prisma.linkUrl,
|
prisma.linkUrl,
|
||||||
prisma.isEnabled,
|
prisma.isEnabled,
|
||||||
|
|
@ -33,7 +64,9 @@ export class NotificationMapper {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toPersistence(entity: NotificationEntity): Omit<PrismaNotification, 'id'> & { id: string } {
|
toPersistence(
|
||||||
|
entity: NotificationEntity,
|
||||||
|
): Omit<PrismaNotification, 'id'> & { id: string } {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
title: entity.title,
|
title: entity.title,
|
||||||
|
|
@ -41,6 +74,7 @@ export class NotificationMapper {
|
||||||
type: entity.type as PrismaNotificationType,
|
type: entity.type as PrismaNotificationType,
|
||||||
priority: entity.priority as PrismaPriority,
|
priority: entity.priority as PrismaPriority,
|
||||||
targetType: entity.targetType as PrismaTargetType,
|
targetType: entity.targetType as PrismaTargetType,
|
||||||
|
targetLogic: 'ANY', // 默认 ANY,后续可扩展
|
||||||
imageUrl: entity.imageUrl,
|
imageUrl: entity.imageUrl,
|
||||||
linkUrl: entity.linkUrl,
|
linkUrl: entity.linkUrl,
|
||||||
isEnabled: entity.isEnabled,
|
isEnabled: entity.isEnabled,
|
||||||
|
|
|
||||||
|
|
@ -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<AudienceSegmentEntity> {
|
||||||
|
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<AudienceSegmentEntity | null> {
|
||||||
|
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<AudienceSegmentEntity[]> {
|
||||||
|
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<AudienceSegmentEntity[]> {
|
||||||
|
return this.findAll({ usageType, isEnabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(params?: {
|
||||||
|
usageType?: SegmentUsageType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.audienceSegment.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEstimatedUsers(id: string, count: number): Promise<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ClassificationRuleEntity> {
|
||||||
|
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<ClassificationRuleEntity | null> {
|
||||||
|
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<ClassificationRuleEntity[]> {
|
||||||
|
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<ClassificationRuleEntity[]> {
|
||||||
|
return this.findAll({ isEnabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLinkedRules(): Promise<RuleWithTag[]> {
|
||||||
|
// 查找所有关联了标签的规则
|
||||||
|
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<number> {
|
||||||
|
return this.prisma.userClassificationRule.count({
|
||||||
|
where: params?.isEnabled !== undefined
|
||||||
|
? { isEnabled: params.isEnabled }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
// 先解除与标签的关联
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
NotificationRepository,
|
NotificationRepository,
|
||||||
|
|
@ -7,31 +7,100 @@ import {
|
||||||
import {
|
import {
|
||||||
NotificationEntity,
|
NotificationEntity,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
TargetType,
|
||||||
} from '../../../domain/entities/notification.entity';
|
} 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()
|
@Injectable()
|
||||||
export class NotificationRepositoryImpl implements NotificationRepository {
|
export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly mapper: NotificationMapper,
|
private readonly mapper: NotificationMapper,
|
||||||
|
@Inject(USER_TAG_REPOSITORY)
|
||||||
|
private readonly tagRepository: UserTagRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async save(notification: NotificationEntity): Promise<NotificationEntity> {
|
async save(notification: NotificationEntity): Promise<NotificationEntity> {
|
||||||
const data = this.mapper.toPersistence(notification);
|
const data = this.mapper.toPersistence(notification);
|
||||||
const saved = await this.prisma.notification.upsert({
|
|
||||||
where: { id: notification.id },
|
// 使用事务保存通知和关联数据
|
||||||
create: data,
|
const saved = await this.prisma.$transaction(async (tx) => {
|
||||||
update: data,
|
// 保存通知主体
|
||||||
|
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<NotificationEntity | null> {
|
async findById(id: string): Promise<NotificationEntity | null> {
|
||||||
const notification = await this.prisma.notification.findUnique({
|
const notification = await this.prisma.notification.findUnique({
|
||||||
where: { id },
|
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?: {
|
async findActiveNotifications(params?: {
|
||||||
|
|
@ -47,11 +116,17 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
...(params?.type && { type: params.type }),
|
...(params?.type && { type: params.type }),
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
targetTags: { select: { tagId: true } },
|
||||||
|
targetUsers: { select: { accountSequence: true } },
|
||||||
|
},
|
||||||
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
||||||
take: params?.limit ?? 50,
|
take: params?.limit ?? 50,
|
||||||
skip: params?.offset ?? 0,
|
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: {
|
async findNotificationsForUser(params: {
|
||||||
|
|
@ -61,18 +136,52 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<NotificationWithReadStatus[]> {
|
}): Promise<NotificationWithReadStatus[]> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// 获取用户的标签
|
||||||
|
const userTagIds = await this.tagRepository.findUserTagIds(
|
||||||
|
params.userSerialNum,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 查询通知
|
||||||
const notifications = await this.prisma.notification.findMany({
|
const notifications = await this.prisma.notification.findMany({
|
||||||
where: {
|
where: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
publishedAt: { lte: now },
|
publishedAt: { lte: now },
|
||||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
...(params.type && { type: params.type }),
|
...(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: {
|
include: {
|
||||||
readRecords: {
|
readRecords: {
|
||||||
where: { userSerialNum: params.userSerialNum },
|
where: { userSerialNum: params.userSerialNum },
|
||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
|
targetTags: { select: { tagId: true } },
|
||||||
|
targetUsers: { select: { accountSequence: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
||||||
take: params.limit ?? 50,
|
take: params.limit ?? 50,
|
||||||
|
|
@ -80,7 +189,7 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
});
|
});
|
||||||
|
|
||||||
return notifications.map((n) => ({
|
return notifications.map((n) => ({
|
||||||
notification: this.mapper.toDomain(n),
|
notification: this.mapper.toDomain(n as NotificationWithTargets),
|
||||||
isRead: n.readRecords.length > 0,
|
isRead: n.readRecords.length > 0,
|
||||||
readAt: n.readRecords[0]?.readAt ?? null,
|
readAt: n.readRecords[0]?.readAt ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -88,6 +197,10 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
|
|
||||||
async countUnreadForUser(userSerialNum: string): Promise<number> {
|
async countUnreadForUser(userSerialNum: string): Promise<number> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// 获取用户的标签
|
||||||
|
const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum);
|
||||||
|
|
||||||
const count = await this.prisma.notification.count({
|
const count = await this.prisma.notification.count({
|
||||||
where: {
|
where: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
|
|
@ -96,12 +209,37 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
readRecords: {
|
readRecords: {
|
||||||
none: { userSerialNum },
|
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;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAsRead(notificationId: string, userSerialNum: string): Promise<void> {
|
async markAsRead(
|
||||||
|
notificationId: string,
|
||||||
|
userSerialNum: string,
|
||||||
|
): Promise<void> {
|
||||||
await this.prisma.notificationRead.upsert({
|
await this.prisma.notificationRead.upsert({
|
||||||
where: {
|
where: {
|
||||||
notificationId_userSerialNum: {
|
notificationId_userSerialNum: {
|
||||||
|
|
@ -119,6 +257,10 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
|
|
||||||
async markAllAsRead(userSerialNum: string): Promise<void> {
|
async markAllAsRead(userSerialNum: string): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// 获取用户的标签
|
||||||
|
const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum);
|
||||||
|
|
||||||
// 获取所有未读的有效通知
|
// 获取所有未读的有效通知
|
||||||
const unreadNotifications = await this.prisma.notification.findMany({
|
const unreadNotifications = await this.prisma.notification.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -128,6 +270,27 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
readRecords: {
|
readRecords: {
|
||||||
none: { userSerialNum },
|
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 },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
@ -161,11 +324,17 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
...(params?.type && { type: params.type }),
|
...(params?.type && { type: params.type }),
|
||||||
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
|
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
targetTags: { select: { tagId: true } },
|
||||||
|
targetUsers: { select: { accountSequence: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: params?.limit ?? 50,
|
take: params?.limit ?? 50,
|
||||||
skip: params?.offset ?? 0,
|
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?: {
|
async count(params?: {
|
||||||
|
|
|
||||||
|
|
@ -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<TagCategoryEntity> {
|
||||||
|
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<TagCategoryEntity | null> {
|
||||||
|
const category = await this.prisma.tagCategory.findUnique({ where: { id } });
|
||||||
|
return category ? this.mapCategoryToDomain(category) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCategoryByCode(code: string): Promise<TagCategoryEntity | null> {
|
||||||
|
const category = await this.prisma.tagCategory.findUnique({ where: { code } });
|
||||||
|
return category ? this.mapCategoryToDomain(category) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllCategories(params?: { isEnabled?: boolean }): Promise<TagCategoryEntity[]> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.tagCategory.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签定义 CRUD
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
async saveTag(tag: UserTagEntity): Promise<UserTagEntity> {
|
||||||
|
// 处理 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<UserTagEntity | null> {
|
||||||
|
const tag = await this.prisma.userTag.findUnique({ where: { id } });
|
||||||
|
return tag ? this.mapTagToDomain(tag) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTagByCode(code: string): Promise<UserTagEntity | null> {
|
||||||
|
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<UserTagEntity[]> {
|
||||||
|
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<UserTagEntity[]> {
|
||||||
|
return this.findAllTags({ isEnabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAutoTags(): Promise<UserTagEntity[]> {
|
||||||
|
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<UserTagEntity[]> {
|
||||||
|
return this.findAllTags({ isEnabled: true, isAdvertisable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async countTags(params?: {
|
||||||
|
categoryId?: string;
|
||||||
|
type?: TagType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.userTag.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEstimatedUsers(tagId: string, count: number): Promise<void> {
|
||||||
|
await this.prisma.userTag.update({
|
||||||
|
where: { id: tagId },
|
||||||
|
data: { estimatedUsers: count },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 用户-标签关联
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
async saveAssignment(assignment: UserTagAssignmentEntity): Promise<UserTagAssignmentEntity> {
|
||||||
|
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<void> {
|
||||||
|
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<UserTagWithAssignment[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<UserTagAssignmentEntity[]> {
|
||||||
|
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<UserTagAssignmentEntity[]> {
|
||||||
|
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<number> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<UserTagAssignmentEntity | null> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.userTagAssignment.deleteMany({
|
||||||
|
where: { accountSequence, tagId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAutoAssignments(accountSequence: string): Promise<void> {
|
||||||
|
await this.prisma.userTagAssignment.deleteMany({
|
||||||
|
where: {
|
||||||
|
accountSequence,
|
||||||
|
source: { startsWith: 'rule:' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAssignmentsByTagId(tagId: string): Promise<void> {
|
||||||
|
await this.prisma.userTagAssignment.deleteMany({
|
||||||
|
where: { tagId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAssignmentsBySource(source: string): Promise<number> {
|
||||||
|
const result = await this.prisma.userTagAssignment.deleteMany({
|
||||||
|
where: { source },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanExpiredAssignments(): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<UserTagLogEntity[]> {
|
||||||
|
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<number> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,38 @@
|
||||||
gap: 12px;
|
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 {
|
&__card {
|
||||||
background: $card-background;
|
background: $card-background;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -265,4 +297,67 @@
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Modal, toast, Button } from '@/components/common';
|
import { Modal, toast, Button } from '@/components/common';
|
||||||
import { PageContainer } from '@/components/layout';
|
import { PageContainer } from '@/components/layout';
|
||||||
|
import { UserTagsTab, AudienceSegmentsTab } from '@/components/features/notifications';
|
||||||
import { cn } from '@/utils/helpers';
|
import { cn } from '@/utils/helpers';
|
||||||
import { formatDateTime } from '@/utils/formatters';
|
import { formatDateTime } from '@/utils/formatters';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,8 +16,18 @@ import {
|
||||||
NOTIFICATION_PRIORITY_OPTIONS,
|
NOTIFICATION_PRIORITY_OPTIONS,
|
||||||
TARGET_TYPE_OPTIONS,
|
TARGET_TYPE_OPTIONS,
|
||||||
} from '@/services/notificationService';
|
} from '@/services/notificationService';
|
||||||
|
import { userTagService, UserTag } from '@/services/userTagService';
|
||||||
import styles from './notifications.module.scss';
|
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 getTypeStyle = (type: NotificationType) => {
|
||||||
const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type);
|
const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type);
|
||||||
|
|
@ -45,11 +56,18 @@ const getTargetLabel = (target: string) => {
|
||||||
* 通知管理页面
|
* 通知管理页面
|
||||||
*/
|
*/
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
|
// Tab 状态
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('notifications');
|
||||||
|
|
||||||
|
// 通知数据状态
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [typeFilter, setTypeFilter] = useState<NotificationType | ''>('');
|
const [typeFilter, setTypeFilter] = useState<NotificationType | ''>('');
|
||||||
|
|
||||||
|
// 可用标签列表(用于定向选择)
|
||||||
|
const [availableTags, setAvailableTags] = useState<UserTag[]>([]);
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingNotification, setEditingNotification] = useState<NotificationItem | null>(null);
|
const [editingNotification, setEditingNotification] = useState<NotificationItem | null>(null);
|
||||||
|
|
@ -62,6 +80,8 @@ export default function NotificationsPage() {
|
||||||
type: 'SYSTEM' as NotificationType,
|
type: 'SYSTEM' as NotificationType,
|
||||||
priority: 'NORMAL' as NotificationPriority,
|
priority: 'NORMAL' as NotificationPriority,
|
||||||
targetType: 'ALL' as TargetType,
|
targetType: 'ALL' as TargetType,
|
||||||
|
selectedTagIds: [] as string[],
|
||||||
|
userIds: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
linkUrl: '',
|
linkUrl: '',
|
||||||
publishedAt: '',
|
publishedAt: '',
|
||||||
|
|
@ -85,9 +105,22 @@ export default function NotificationsPage() {
|
||||||
}
|
}
|
||||||
}, [typeFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
loadNotifications();
|
if (activeTab === 'notifications') {
|
||||||
}, [loadNotifications]);
|
loadNotifications();
|
||||||
|
loadTags();
|
||||||
|
}
|
||||||
|
}, [activeTab, loadNotifications, loadTags]);
|
||||||
|
|
||||||
// 打开创建弹窗
|
// 打开创建弹窗
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
|
|
@ -98,6 +131,8 @@ export default function NotificationsPage() {
|
||||||
type: 'SYSTEM',
|
type: 'SYSTEM',
|
||||||
priority: 'NORMAL',
|
priority: 'NORMAL',
|
||||||
targetType: 'ALL',
|
targetType: 'ALL',
|
||||||
|
selectedTagIds: [],
|
||||||
|
userIds: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
linkUrl: '',
|
linkUrl: '',
|
||||||
publishedAt: '',
|
publishedAt: '',
|
||||||
|
|
@ -115,6 +150,8 @@ export default function NotificationsPage() {
|
||||||
type: notification.type,
|
type: notification.type,
|
||||||
priority: notification.priority,
|
priority: notification.priority,
|
||||||
targetType: notification.targetType,
|
targetType: notification.targetType,
|
||||||
|
selectedTagIds: notification.targetConfig?.tagIds || [],
|
||||||
|
userIds: notification.targetConfig?.accountSequences?.join('\n') || '',
|
||||||
imageUrl: notification.imageUrl || '',
|
imageUrl: notification.imageUrl || '',
|
||||||
linkUrl: notification.linkUrl || '',
|
linkUrl: notification.linkUrl || '',
|
||||||
publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '',
|
publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '',
|
||||||
|
|
@ -140,6 +177,27 @@ export default function NotificationsPage() {
|
||||||
return;
|
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 {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: formData.title.trim(),
|
title: formData.title.trim(),
|
||||||
|
|
@ -147,6 +205,7 @@ export default function NotificationsPage() {
|
||||||
type: formData.type,
|
type: formData.type,
|
||||||
priority: formData.priority,
|
priority: formData.priority,
|
||||||
targetType: formData.targetType,
|
targetType: formData.targetType,
|
||||||
|
targetConfig,
|
||||||
imageUrl: formData.imageUrl.trim() || undefined,
|
imageUrl: formData.imageUrl.trim() || undefined,
|
||||||
linkUrl: formData.linkUrl.trim() || undefined,
|
linkUrl: formData.linkUrl.trim() || undefined,
|
||||||
publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : 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) => {
|
const handleToggle = async (notification: NotificationItem) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -194,118 +263,156 @@ export default function NotificationsPage() {
|
||||||
return (
|
return (
|
||||||
<PageContainer title="通知管理">
|
<PageContainer title="通知管理">
|
||||||
<div className={styles.notifications}>
|
<div className={styles.notifications}>
|
||||||
{/* 页面标题和操作按钮 */}
|
{/* 页面标题 */}
|
||||||
<div className={styles.notifications__header}>
|
<div className={styles.notifications__header}>
|
||||||
<h1 className={styles.notifications__title}>通知管理</h1>
|
<h1 className={styles.notifications__title}>通知与用户画像</h1>
|
||||||
<div className={styles.notifications__actions}>
|
|
||||||
<Button variant="primary" onClick={handleCreate}>
|
|
||||||
+ 新建通知
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主内容卡片 */}
|
{/* Tab 导航 */}
|
||||||
<div className={styles.notifications__card}>
|
<div className={styles.notifications__tabs}>
|
||||||
{/* 筛选区域 */}
|
{TABS.map(tab => (
|
||||||
<div className={styles.notifications__filters}>
|
<button
|
||||||
<select
|
key={tab.key}
|
||||||
className={styles.notifications__select}
|
className={cn(
|
||||||
value={typeFilter}
|
styles.notifications__tab,
|
||||||
onChange={(e) => setTypeFilter(e.target.value as NotificationType | '')}
|
activeTab === tab.key && styles['notifications__tab--active']
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
>
|
||||||
<option value="">全部类型</option>
|
{tab.label}
|
||||||
{NOTIFICATION_TYPE_OPTIONS.map((opt) => (
|
</button>
|
||||||
<option key={opt.value} value={opt.value}>
|
))}
|
||||||
{opt.label}
|
</div>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Button variant="outline" size="sm" onClick={loadNotifications}>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 通知列表 */}
|
{/* Tab 内容 */}
|
||||||
<div className={styles.notifications__list}>
|
{activeTab === 'notifications' && (
|
||||||
{loading ? (
|
<>
|
||||||
<div className={styles.notifications__loading}>加载中...</div>
|
{/* 主内容卡片 */}
|
||||||
) : error ? (
|
<div className={styles.notifications__card}>
|
||||||
<div className={styles.notifications__error}>
|
{/* 筛选区域 */}
|
||||||
<span>{error}</span>
|
<div className={styles.notifications__filters}>
|
||||||
|
<select
|
||||||
|
className={styles.notifications__select}
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value as NotificationType | '')}
|
||||||
|
>
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
{NOTIFICATION_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<Button variant="outline" size="sm" onClick={loadNotifications}>
|
<Button variant="outline" size="sm" onClick={loadNotifications}>
|
||||||
重试
|
刷新
|
||||||
|
</Button>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" onClick={handleCreate}>
|
||||||
|
+ 新建通知
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className={styles.notifications__empty}>
|
{/* 通知列表 */}
|
||||||
暂无通知,点击"新建通知"创建第一条通知
|
<div className={styles.notifications__list}>
|
||||||
</div>
|
{loading ? (
|
||||||
) : (
|
<div className={styles.notifications__loading}>加载中...</div>
|
||||||
notifications.map((notification) => (
|
) : error ? (
|
||||||
<div
|
<div className={styles.notifications__error}>
|
||||||
key={notification.id}
|
<span>{error}</span>
|
||||||
className={cn(
|
<Button variant="outline" size="sm" onClick={loadNotifications}>
|
||||||
styles.notifications__item,
|
重试
|
||||||
!notification.isEnabled && styles['notifications__item--disabled']
|
</Button>
|
||||||
)}
|
</div>
|
||||||
>
|
) : notifications.length === 0 ? (
|
||||||
<div className={styles.notifications__itemHeader}>
|
<div className={styles.notifications__empty}>
|
||||||
<div className={styles.notifications__itemMeta}>
|
暂无通知,点击"新建通知"创建第一条通知
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
) : (
|
||||||
styles.notifications__tag,
|
notifications.map((notification) => (
|
||||||
styles[`notifications__tag--${getTypeStyle(notification.type)}`]
|
<div
|
||||||
)}
|
key={notification.id}
|
||||||
>
|
className={cn(
|
||||||
{getTypeLabel(notification.type)}
|
styles.notifications__item,
|
||||||
</span>
|
!notification.isEnabled && styles['notifications__item--disabled']
|
||||||
<span className={styles.notifications__priority}>
|
|
||||||
{getPriorityLabel(notification.priority)}
|
|
||||||
</span>
|
|
||||||
<span className={styles.notifications__target}>
|
|
||||||
{getTargetLabel(notification.targetType)}
|
|
||||||
</span>
|
|
||||||
{!notification.isEnabled && (
|
|
||||||
<span className={styles.notifications__disabled}>已禁用</span>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<div className={styles.notifications__itemHeader}>
|
||||||
|
<div className={styles.notifications__itemMeta}>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
styles.notifications__tag,
|
||||||
|
styles[`notifications__tag--${getTypeStyle(notification.type)}`]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getTypeLabel(notification.type)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.notifications__priority}>
|
||||||
|
{getPriorityLabel(notification.priority)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.notifications__target}>
|
||||||
|
{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}个用户)</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{!notification.isEnabled && (
|
||||||
|
<span className={styles.notifications__disabled}>已禁用</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.notifications__itemActions}>
|
||||||
|
<button
|
||||||
|
className={styles.notifications__actionBtn}
|
||||||
|
onClick={() => handleToggle(notification)}
|
||||||
|
>
|
||||||
|
{notification.isEnabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.notifications__actionBtn}
|
||||||
|
onClick={() => handleEdit(notification)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(styles.notifications__actionBtn, styles['notifications__actionBtn--danger'])}
|
||||||
|
onClick={() => setDeleteConfirm(notification.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className={styles.notifications__itemTitle}>{notification.title}</h3>
|
||||||
|
<p className={styles.notifications__itemContent}>{notification.content}</p>
|
||||||
|
<div className={styles.notifications__itemFooter}>
|
||||||
|
<span>创建时间: {formatDateTime(notification.createdAt)}</span>
|
||||||
|
{notification.publishedAt && (
|
||||||
|
<span>发布时间: {formatDateTime(notification.publishedAt)}</span>
|
||||||
|
)}
|
||||||
|
{notification.expiresAt && (
|
||||||
|
<span>过期时间: {formatDateTime(notification.expiresAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.notifications__itemActions}>
|
))
|
||||||
<button
|
)}
|
||||||
className={styles.notifications__actionBtn}
|
</div>
|
||||||
onClick={() => handleToggle(notification)}
|
</div>
|
||||||
>
|
</>
|
||||||
{notification.isEnabled ? '禁用' : '启用'}
|
)}
|
||||||
</button>
|
|
||||||
<button
|
{activeTab === 'tags' && (
|
||||||
className={styles.notifications__actionBtn}
|
<div className={styles.notifications__card}>
|
||||||
onClick={() => handleEdit(notification)}
|
<UserTagsTab />
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={cn(styles.notifications__actionBtn, styles['notifications__actionBtn--danger'])}
|
|
||||||
onClick={() => setDeleteConfirm(notification.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className={styles.notifications__itemTitle}>{notification.title}</h3>
|
|
||||||
<p className={styles.notifications__itemContent}>{notification.content}</p>
|
|
||||||
<div className={styles.notifications__itemFooter}>
|
|
||||||
<span>创建时间: {formatDateTime(notification.createdAt)}</span>
|
|
||||||
{notification.publishedAt && (
|
|
||||||
<span>发布时间: {formatDateTime(notification.publishedAt)}</span>
|
|
||||||
)}
|
|
||||||
{notification.expiresAt && (
|
|
||||||
<span>过期时间: {formatDateTime(notification.expiresAt)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'segments' && (
|
||||||
|
<div className={styles.notifications__card}>
|
||||||
|
<AudienceSegmentsTab />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 创建/编辑弹窗 */}
|
{/* 创建/编辑弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -322,7 +429,7 @@ export default function NotificationsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
width={640}
|
width={720}
|
||||||
>
|
>
|
||||||
<div className={styles.notifications__form}>
|
<div className={styles.notifications__form}>
|
||||||
<div className={styles.notifications__formGroup}>
|
<div className={styles.notifications__formGroup}>
|
||||||
|
|
@ -390,6 +497,63 @@ export default function NotificationsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 按标签筛选 */}
|
||||||
|
{formData.targetType === 'BY_TAG' && (
|
||||||
|
<div className={styles.notifications__formGroup}>
|
||||||
|
<label>选择标签 (满足任一标签的用户)</label>
|
||||||
|
<div className={styles.notifications__tagSelector}>
|
||||||
|
{availableTags.length === 0 ? (
|
||||||
|
<div className={styles.notifications__empty} style={{ padding: '20px' }}>
|
||||||
|
暂无可用标签,请先在"用户标签"中创建
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.notifications__tagList}>
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<label
|
||||||
|
key={tag.id}
|
||||||
|
className={cn(
|
||||||
|
styles.notifications__tagOption,
|
||||||
|
formData.selectedTagIds.includes(tag.id) && styles['notifications__tagOption--selected']
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.selectedTagIds.includes(tag.id)}
|
||||||
|
onChange={() => toggleTagSelection(tag.id)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={styles.notifications__tagColor}
|
||||||
|
style={{ backgroundColor: tag.color || '#6B7280' }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={styles.notifications__targetHint}>
|
||||||
|
已选择 {formData.selectedTagIds.length} 个标签
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 指定用户 */}
|
||||||
|
{formData.targetType === 'SPECIFIC' && (
|
||||||
|
<div className={styles.notifications__formGroup}>
|
||||||
|
<label>用户ID列表</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.notifications__userIdsInput}
|
||||||
|
value={formData.userIds}
|
||||||
|
onChange={(e) => setFormData({ ...formData, userIds: e.target.value })}
|
||||||
|
placeholder="请输入用户ID,每行一个或用逗号分隔"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<span className={styles.notifications__targetHint}>
|
||||||
|
支持换行或逗号分隔,当前 {formData.userIds.split(/[\n,]/).filter(s => s.trim()).length} 个用户
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.notifications__formRow}>
|
<div className={styles.notifications__formRow}>
|
||||||
<div className={styles.notifications__formGroup}>
|
<div className={styles.notifications__formGroup}>
|
||||||
<label>发布时间</label>
|
<label>发布时间</label>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,340 @@
|
||||||
|
.container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--gray {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
&--green {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
&--yellow {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLogic {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionItems {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition {
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #374151;
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单样式
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsSection {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsSectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupsLogic {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionGroupEdit {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupControls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #dc2626;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionsList {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditionRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCondBtn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed #93c5fd;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addGroupBtn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed #93c5fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Modal, toast, Button } from '@/components/common';
|
||||||
|
import { cn } from '@/utils/helpers';
|
||||||
|
import { formatDateTime } from '@/utils/formatters';
|
||||||
|
import {
|
||||||
|
audienceSegmentService,
|
||||||
|
AudienceSegment,
|
||||||
|
SegmentCondition,
|
||||||
|
SegmentConditionGroup,
|
||||||
|
SegmentStatus,
|
||||||
|
LogicOperator,
|
||||||
|
ConditionOperator,
|
||||||
|
SEGMENT_STATUS_OPTIONS,
|
||||||
|
CONDITION_OPERATOR_OPTIONS,
|
||||||
|
SEGMENT_FIELD_OPTIONS,
|
||||||
|
} from '@/services/audienceSegmentService';
|
||||||
|
import { userTagService, UserTag } from '@/services/userTagService';
|
||||||
|
import styles from './AudienceSegmentsTab.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人群包管理 Tab
|
||||||
|
*/
|
||||||
|
export const AudienceSegmentsTab = () => {
|
||||||
|
// 数据状态
|
||||||
|
const [segments, setSegments] = useState<AudienceSegment[]>([]);
|
||||||
|
const [tags, setTags] = useState<UserTag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingSegment, setEditingSegment] = useState<AudienceSegment | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
groupsLogic: 'AND' as LogicOperator,
|
||||||
|
conditionGroups: [createEmptyGroup()] as SegmentConditionGroup[],
|
||||||
|
});
|
||||||
|
|
||||||
|
function createEmptyCondition(): SegmentCondition {
|
||||||
|
return { field: 'registrationDays', operator: 'GREATER_THAN', value: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyGroup(): SegmentConditionGroup {
|
||||||
|
return { logic: 'AND', conditions: [createEmptyCondition()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [segmentsData, tagsData] = await Promise.all([
|
||||||
|
audienceSegmentService.getSegments(),
|
||||||
|
userTagService.getTags({ isAdvertisable: true }),
|
||||||
|
]);
|
||||||
|
setSegments(segmentsData);
|
||||||
|
setTags(tagsData);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// 打开创建弹窗
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingSegment(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
groupsLogic: 'AND',
|
||||||
|
conditionGroups: [createEmptyGroup()],
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开编辑弹窗
|
||||||
|
const handleEdit = (segment: AudienceSegment) => {
|
||||||
|
setEditingSegment(segment);
|
||||||
|
setFormData({
|
||||||
|
name: segment.name,
|
||||||
|
description: segment.description || '',
|
||||||
|
groupsLogic: segment.groupsLogic,
|
||||||
|
conditionGroups: segment.conditionGroups.length > 0
|
||||||
|
? segment.conditionGroups
|
||||||
|
: [createEmptyGroup()],
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error('请输入人群包名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤空条件
|
||||||
|
const validGroups = formData.conditionGroups
|
||||||
|
.map(g => ({
|
||||||
|
...g,
|
||||||
|
conditions: g.conditions.filter(c => c.field && c.operator),
|
||||||
|
}))
|
||||||
|
.filter(g => g.conditions.length > 0);
|
||||||
|
|
||||||
|
if (validGroups.length === 0) {
|
||||||
|
toast.error('请至少添加一个有效条件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingSegment) {
|
||||||
|
await audienceSegmentService.updateSegment(editingSegment.id, {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
groupsLogic: formData.groupsLogic,
|
||||||
|
conditionGroups: validGroups,
|
||||||
|
});
|
||||||
|
toast.success('人群包已更新');
|
||||||
|
} else {
|
||||||
|
await audienceSegmentService.createSegment({
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
groupsLogic: formData.groupsLogic,
|
||||||
|
conditionGroups: validGroups,
|
||||||
|
});
|
||||||
|
toast.success('人群包已创建');
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteConfirm) return;
|
||||||
|
try {
|
||||||
|
await audienceSegmentService.deleteSegment(deleteConfirm);
|
||||||
|
toast.success('人群包已删除');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const handleRefresh = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await audienceSegmentService.refreshSegment(id);
|
||||||
|
toast.success('人群包已刷新');
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '刷新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 条件组操作
|
||||||
|
const addConditionGroup = () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
conditionGroups: [...formData.conditionGroups, createEmptyGroup()],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeConditionGroup = (groupIndex: number) => {
|
||||||
|
const groups = [...formData.conditionGroups];
|
||||||
|
groups.splice(groupIndex, 1);
|
||||||
|
setFormData({ ...formData, conditionGroups: groups.length > 0 ? groups : [createEmptyGroup()] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConditionGroup = (groupIndex: number, updates: Partial<SegmentConditionGroup>) => {
|
||||||
|
const groups = [...formData.conditionGroups];
|
||||||
|
groups[groupIndex] = { ...groups[groupIndex], ...updates };
|
||||||
|
setFormData({ ...formData, conditionGroups: groups });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 条件操作
|
||||||
|
const addCondition = (groupIndex: number) => {
|
||||||
|
const groups = [...formData.conditionGroups];
|
||||||
|
groups[groupIndex].conditions.push(createEmptyCondition());
|
||||||
|
setFormData({ ...formData, conditionGroups: groups });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCondition = (groupIndex: number, condIndex: number) => {
|
||||||
|
const groups = [...formData.conditionGroups];
|
||||||
|
groups[groupIndex].conditions.splice(condIndex, 1);
|
||||||
|
if (groups[groupIndex].conditions.length === 0) {
|
||||||
|
groups[groupIndex].conditions.push(createEmptyCondition());
|
||||||
|
}
|
||||||
|
setFormData({ ...formData, conditionGroups: groups });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCondition = (groupIndex: number, condIndex: number, updates: Partial<SegmentCondition>) => {
|
||||||
|
const groups = [...formData.conditionGroups];
|
||||||
|
groups[groupIndex].conditions[condIndex] = { ...groups[groupIndex].conditions[condIndex], ...updates };
|
||||||
|
setFormData({ ...formData, conditionGroups: groups });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取状态显示
|
||||||
|
const getStatusLabel = (status: SegmentStatus) => {
|
||||||
|
const opt = SEGMENT_STATUS_OPTIONS.find(o => o.value === status);
|
||||||
|
return opt?.label || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: SegmentStatus) => {
|
||||||
|
const opt = SEGMENT_STATUS_OPTIONS.find(o => o.value === status);
|
||||||
|
return opt?.color || 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取字段标签
|
||||||
|
const getFieldLabel = (field: string) => {
|
||||||
|
const opt = SEGMENT_FIELD_OPTIONS.find(o => o.value === field);
|
||||||
|
return opt?.label || field;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取操作符标签
|
||||||
|
const getOperatorLabel = (op: ConditionOperator) => {
|
||||||
|
const opt = CONDITION_OPERATOR_OPTIONS.find(o => o.value === op);
|
||||||
|
return opt?.label || op;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && segments.length === 0) {
|
||||||
|
return <div className={styles.loading}>加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h3>人群包管理</h3>
|
||||||
|
<Button variant="primary" onClick={handleCreate}>+ 新建人群包</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<div className={styles.empty}>暂无人群包,点击"新建人群包"创建</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{segments.map(segment => (
|
||||||
|
<div key={segment.id} className={styles.card}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<h4>{segment.name}</h4>
|
||||||
|
<span className={cn(styles.status, styles[`status--${getStatusColor(segment.status)}`])}>
|
||||||
|
{getStatusLabel(segment.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{segment.description && <p className={styles.desc}>{segment.description}</p>}
|
||||||
|
<div className={styles.conditions}>
|
||||||
|
{segment.conditionGroups.map((group, gi) => (
|
||||||
|
<div key={gi} className={styles.conditionGroup}>
|
||||||
|
{gi > 0 && <span className={styles.groupLogic}>{segment.groupsLogic}</span>}
|
||||||
|
<div className={styles.conditionItems}>
|
||||||
|
{group.conditions.map((cond, ci) => (
|
||||||
|
<span key={ci} className={styles.condition}>
|
||||||
|
{ci > 0 && <em>{group.logic}</em>}
|
||||||
|
{getFieldLabel(cond.field)} {getOperatorLabel(cond.operator)} {String(cond.value || '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.meta}>
|
||||||
|
<span>预估用户: {segment.estimatedUsers?.toLocaleString() || '-'}</span>
|
||||||
|
{segment.lastRefreshedAt && (
|
||||||
|
<span>上次刷新: {formatDateTime(segment.lastRefreshedAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button onClick={() => handleRefresh(segment.id)}>刷新</button>
|
||||||
|
<button onClick={() => handleEdit(segment)}>编辑</button>
|
||||||
|
<button onClick={() => setDeleteConfirm(segment.id)}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showModal}
|
||||||
|
title={editingSegment ? '编辑人群包' : '新建人群包'}
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="outline" onClick={() => setShowModal(false)}>取消</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave}>保存</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>人群包名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="如: 高价值用户, 沉睡用户"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="人群包说明"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条件组 */}
|
||||||
|
<div className={styles.conditionsSection}>
|
||||||
|
<div className={styles.conditionsSectionHeader}>
|
||||||
|
<label>筛选条件</label>
|
||||||
|
<div className={styles.groupsLogic}>
|
||||||
|
<span>条件组之间:</span>
|
||||||
|
<select
|
||||||
|
value={formData.groupsLogic}
|
||||||
|
onChange={e => setFormData({ ...formData, groupsLogic: e.target.value as LogicOperator })}
|
||||||
|
>
|
||||||
|
<option value="AND">全部满足 (AND)</option>
|
||||||
|
<option value="OR">任一满足 (OR)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.conditionGroups.map((group, gi) => (
|
||||||
|
<div key={gi} className={styles.conditionGroupEdit}>
|
||||||
|
<div className={styles.groupHeader}>
|
||||||
|
<span>条件组 {gi + 1}</span>
|
||||||
|
<div className={styles.groupControls}>
|
||||||
|
<select
|
||||||
|
value={group.logic}
|
||||||
|
onChange={e => updateConditionGroup(gi, { logic: e.target.value as LogicOperator })}
|
||||||
|
>
|
||||||
|
<option value="AND">全部满足</option>
|
||||||
|
<option value="OR">任一满足</option>
|
||||||
|
</select>
|
||||||
|
{formData.conditionGroups.length > 1 && (
|
||||||
|
<button onClick={() => removeConditionGroup(gi)}>删除组</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.conditionsList}>
|
||||||
|
{group.conditions.map((cond, ci) => (
|
||||||
|
<div key={ci} className={styles.conditionRow}>
|
||||||
|
<select
|
||||||
|
value={cond.field}
|
||||||
|
onChange={e => updateCondition(gi, ci, { field: e.target.value })}
|
||||||
|
>
|
||||||
|
{SEGMENT_FIELD_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={cond.operator}
|
||||||
|
onChange={e => updateCondition(gi, ci, { operator: e.target.value as ConditionOperator })}
|
||||||
|
>
|
||||||
|
{CONDITION_OPERATOR_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{cond.field === 'tag' ? (
|
||||||
|
<select
|
||||||
|
value={String(cond.value || '')}
|
||||||
|
onChange={e => updateCondition(gi, ci, { value: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">选择标签</option>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<option key={tag.id} value={tag.code}>{tag.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={String(cond.value || '')}
|
||||||
|
onChange={e => updateCondition(gi, ci, { value: e.target.value })}
|
||||||
|
placeholder="值"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => removeCondition(gi, ci)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className={styles.addCondBtn} onClick={() => addCondition(gi)}>
|
||||||
|
+ 添加条件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className={styles.addGroupBtn} onClick={addConditionGroup}>
|
||||||
|
+ 添加条件组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={!!deleteConfirm}
|
||||||
|
title="确认删除"
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>取消</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete}>确认删除</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<p>确定要删除这个人群包吗?此操作无法撤销。</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudienceSegmentsTab;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { AudienceSegmentsTab } from './AudienceSegmentsTab';
|
||||||
|
export { default } from './AudienceSegmentsTab';
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
height: calc(100vh - 280px);
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧分类列表
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryList {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100% - 60px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryActions {
|
||||||
|
display: none;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.categoryItem:hover & {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧标签列表
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagCard {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagColor {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagName {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagType {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
&--green {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
&--purple {
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
&--gray {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagCode {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adBadge {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单样式
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorPicker {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,504 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Modal, toast, Button } from '@/components/common';
|
||||||
|
import { cn } from '@/utils/helpers';
|
||||||
|
import {
|
||||||
|
userTagService,
|
||||||
|
TagCategory,
|
||||||
|
UserTag,
|
||||||
|
TagType,
|
||||||
|
TagValueType,
|
||||||
|
TAG_TYPE_OPTIONS,
|
||||||
|
TAG_VALUE_TYPE_OPTIONS,
|
||||||
|
TAG_COLORS,
|
||||||
|
} from '@/services/userTagService';
|
||||||
|
import styles from './UserTagsTab.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户标签管理 Tab
|
||||||
|
*/
|
||||||
|
export const UserTagsTab = () => {
|
||||||
|
// 数据状态
|
||||||
|
const [categories, setCategories] = useState<TagCategory[]>([]);
|
||||||
|
const [tags, setTags] = useState<UserTag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 分类弹窗状态
|
||||||
|
const [showCategoryModal, setShowCategoryModal] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<TagCategory | null>(null);
|
||||||
|
const [categoryForm, setCategoryForm] = useState({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 标签弹窗状态
|
||||||
|
const [showTagModal, setShowTagModal] = useState(false);
|
||||||
|
const [editingTag, setEditingTag] = useState<UserTag | null>(null);
|
||||||
|
const [tagForm, setTagForm] = useState({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: TAG_COLORS[0],
|
||||||
|
type: 'MANUAL' as TagType,
|
||||||
|
valueType: 'BOOLEAN' as TagValueType,
|
||||||
|
enumValues: '',
|
||||||
|
isAdvertisable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'category' | 'tag'; id: string } | null>(null);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [categoriesData, tagsData] = await Promise.all([
|
||||||
|
userTagService.getCategories(),
|
||||||
|
userTagService.getTags({ categoryId: activeCategory ?? undefined }),
|
||||||
|
]);
|
||||||
|
setCategories(categoriesData);
|
||||||
|
setTags(tagsData);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeCategory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 分类操作
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
const handleCreateCategory = () => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setCategoryForm({ code: '', name: '', description: '' });
|
||||||
|
setShowCategoryModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCategory = (category: TagCategory) => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setCategoryForm({
|
||||||
|
code: category.code,
|
||||||
|
name: category.name,
|
||||||
|
description: category.description || '',
|
||||||
|
});
|
||||||
|
setShowCategoryModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCategory = async () => {
|
||||||
|
if (!categoryForm.name.trim()) {
|
||||||
|
toast.error('请输入分类名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingCategory && !categoryForm.code.trim()) {
|
||||||
|
toast.error('请输入分类代码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await userTagService.updateCategory(editingCategory.id, {
|
||||||
|
name: categoryForm.name.trim(),
|
||||||
|
description: categoryForm.description.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success('分类已更新');
|
||||||
|
} else {
|
||||||
|
await userTagService.createCategory({
|
||||||
|
code: categoryForm.code.trim(),
|
||||||
|
name: categoryForm.name.trim(),
|
||||||
|
description: categoryForm.description.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success('分类已创建');
|
||||||
|
}
|
||||||
|
setShowCategoryModal(false);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = async () => {
|
||||||
|
if (!deleteConfirm || deleteConfirm.type !== 'category') return;
|
||||||
|
try {
|
||||||
|
await userTagService.deleteCategory(deleteConfirm.id);
|
||||||
|
toast.success('分类已删除');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
if (activeCategory === deleteConfirm.id) {
|
||||||
|
setActiveCategory(null);
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签操作
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
const handleCreateTag = () => {
|
||||||
|
setEditingTag(null);
|
||||||
|
setTagForm({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: TAG_COLORS[0],
|
||||||
|
type: 'MANUAL',
|
||||||
|
valueType: 'BOOLEAN',
|
||||||
|
enumValues: '',
|
||||||
|
isAdvertisable: true,
|
||||||
|
});
|
||||||
|
setShowTagModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTag = (tag: UserTag) => {
|
||||||
|
setEditingTag(tag);
|
||||||
|
setTagForm({
|
||||||
|
code: tag.code,
|
||||||
|
name: tag.name,
|
||||||
|
description: tag.description || '',
|
||||||
|
color: tag.color || TAG_COLORS[0],
|
||||||
|
type: tag.type,
|
||||||
|
valueType: tag.valueType,
|
||||||
|
enumValues: tag.enumValues?.join(', ') || '',
|
||||||
|
isAdvertisable: tag.isAdvertisable,
|
||||||
|
});
|
||||||
|
setShowTagModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTag = async () => {
|
||||||
|
if (!tagForm.name.trim()) {
|
||||||
|
toast.error('请输入标签名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingTag && !tagForm.code.trim()) {
|
||||||
|
toast.error('请输入标签代码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enumValues = tagForm.valueType === 'ENUM' && tagForm.enumValues.trim()
|
||||||
|
? tagForm.enumValues.split(',').map(v => v.trim()).filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (editingTag) {
|
||||||
|
await userTagService.updateTag(editingTag.id, {
|
||||||
|
categoryId: activeCategory || undefined,
|
||||||
|
name: tagForm.name.trim(),
|
||||||
|
description: tagForm.description.trim() || undefined,
|
||||||
|
color: tagForm.color,
|
||||||
|
type: tagForm.type,
|
||||||
|
valueType: tagForm.valueType,
|
||||||
|
enumValues,
|
||||||
|
isAdvertisable: tagForm.isAdvertisable,
|
||||||
|
});
|
||||||
|
toast.success('标签已更新');
|
||||||
|
} else {
|
||||||
|
await userTagService.createTag({
|
||||||
|
categoryId: activeCategory || undefined,
|
||||||
|
code: tagForm.code.trim(),
|
||||||
|
name: tagForm.name.trim(),
|
||||||
|
description: tagForm.description.trim() || undefined,
|
||||||
|
color: tagForm.color,
|
||||||
|
type: tagForm.type,
|
||||||
|
valueType: tagForm.valueType,
|
||||||
|
enumValues,
|
||||||
|
isAdvertisable: tagForm.isAdvertisable,
|
||||||
|
});
|
||||||
|
toast.success('标签已创建');
|
||||||
|
}
|
||||||
|
setShowTagModal(false);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTag = async () => {
|
||||||
|
if (!deleteConfirm || deleteConfirm.type !== 'tag') return;
|
||||||
|
try {
|
||||||
|
await userTagService.deleteTag(deleteConfirm.id);
|
||||||
|
toast.success('标签已删除');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取标签类型显示
|
||||||
|
const getTagTypeLabel = (type: TagType) => {
|
||||||
|
const opt = TAG_TYPE_OPTIONS.find(o => o.value === type);
|
||||||
|
return opt?.label || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagTypeColor = (type: TagType) => {
|
||||||
|
const opt = TAG_TYPE_OPTIONS.find(o => o.value === type);
|
||||||
|
return opt?.color || 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && categories.length === 0) {
|
||||||
|
return <div className={styles.loading}>加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 左侧分类列表 */}
|
||||||
|
<div className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarHeader}>
|
||||||
|
<h3>标签分类</h3>
|
||||||
|
<button className={styles.addBtn} onClick={handleCreateCategory}>+</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.categoryList}>
|
||||||
|
<div
|
||||||
|
className={cn(styles.categoryItem, !activeCategory && styles.active)}
|
||||||
|
onClick={() => setActiveCategory(null)}
|
||||||
|
>
|
||||||
|
<span>全部标签</span>
|
||||||
|
<span className={styles.count}>{tags.length}</span>
|
||||||
|
</div>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className={cn(styles.categoryItem, activeCategory === cat.id && styles.active)}
|
||||||
|
onClick={() => setActiveCategory(cat.id)}
|
||||||
|
>
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
<div className={styles.categoryActions}>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); handleEditCategory(cat); }}>编辑</button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ type: 'category', id: cat.id }); }}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧标签列表 */}
|
||||||
|
<div className={styles.main}>
|
||||||
|
<div className={styles.mainHeader}>
|
||||||
|
<h3>{activeCategory ? categories.find(c => c.id === activeCategory)?.name : '全部标签'}</h3>
|
||||||
|
<Button variant="primary" size="sm" onClick={handleCreateTag}>+ 新建标签</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>加载中...</div>
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<div className={styles.empty}>暂无标签,点击"新建标签"创建</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tagGrid}>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<div key={tag.id} className={styles.tagCard}>
|
||||||
|
<div className={styles.tagHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.tagColor}
|
||||||
|
style={{ backgroundColor: tag.color || '#6B7280' }}
|
||||||
|
/>
|
||||||
|
<span className={styles.tagName}>{tag.name}</span>
|
||||||
|
<span className={cn(styles.tagType, styles[`tagType--${getTagTypeColor(tag.type)}`])}>
|
||||||
|
{getTagTypeLabel(tag.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tagCode}>代码: {tag.code}</div>
|
||||||
|
{tag.description && <div className={styles.tagDesc}>{tag.description}</div>}
|
||||||
|
<div className={styles.tagMeta}>
|
||||||
|
{tag.estimatedUsers !== null && (
|
||||||
|
<span>预估用户: {tag.estimatedUsers.toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
{tag.isAdvertisable && <span className={styles.adBadge}>可投放</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tagActions}>
|
||||||
|
<button onClick={() => handleEditTag(tag)}>编辑</button>
|
||||||
|
<button onClick={() => setDeleteConfirm({ type: 'tag', id: tag.id })}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showCategoryModal}
|
||||||
|
title={editingCategory ? '编辑分类' : '新建分类'}
|
||||||
|
onClose={() => setShowCategoryModal(false)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="outline" onClick={() => setShowCategoryModal(false)}>取消</Button>
|
||||||
|
<Button variant="primary" onClick={handleSaveCategory}>保存</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div className={styles.form}>
|
||||||
|
{!editingCategory && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>分类代码 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryForm.code}
|
||||||
|
onChange={e => setCategoryForm({ ...categoryForm, code: e.target.value })}
|
||||||
|
placeholder="如: lifecycle, behavior"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>分类名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryForm.name}
|
||||||
|
onChange={e => setCategoryForm({ ...categoryForm, name: e.target.value })}
|
||||||
|
placeholder="如: 生命周期, 行为特征"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea
|
||||||
|
value={categoryForm.description}
|
||||||
|
onChange={e => setCategoryForm({ ...categoryForm, description: e.target.value })}
|
||||||
|
placeholder="分类说明"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 标签弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={showTagModal}
|
||||||
|
title={editingTag ? '编辑标签' : '新建标签'}
|
||||||
|
onClose={() => setShowTagModal(false)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="outline" onClick={() => setShowTagModal(false)}>取消</Button>
|
||||||
|
<Button variant="primary" onClick={handleSaveTag}>保存</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<div className={styles.form}>
|
||||||
|
{!editingTag && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>标签代码 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagForm.code}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, code: e.target.value })}
|
||||||
|
placeholder="如: vip, new_user, whale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>标签名称 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagForm.name}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, name: e.target.value })}
|
||||||
|
placeholder="如: VIP用户, 新用户"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea
|
||||||
|
value={tagForm.description}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, description: e.target.value })}
|
||||||
|
placeholder="标签说明"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>标签类型</label>
|
||||||
|
<select
|
||||||
|
value={tagForm.type}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, type: e.target.value as TagType })}
|
||||||
|
>
|
||||||
|
{TAG_TYPE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>值类型</label>
|
||||||
|
<select
|
||||||
|
value={tagForm.valueType}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, valueType: e.target.value as TagValueType })}
|
||||||
|
>
|
||||||
|
{TAG_VALUE_TYPE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tagForm.valueType === 'ENUM' && (
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>枚举值 (逗号分隔)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagForm.enumValues}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, enumValues: e.target.value })}
|
||||||
|
placeholder="如: 高, 中, 低"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>标签颜色</label>
|
||||||
|
<div className={styles.colorPicker}>
|
||||||
|
{TAG_COLORS.map(color => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className={cn(styles.colorBtn, tagForm.color === color && styles.selected)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => setTagForm({ ...tagForm, color })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tagForm.isAdvertisable}
|
||||||
|
onChange={e => setTagForm({ ...tagForm, isAdvertisable: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>可用于广告定向</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={!!deleteConfirm}
|
||||||
|
title="确认删除"
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={deleteConfirm?.type === 'category' ? handleDeleteCategory : handleDeleteTag}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{deleteConfirm?.type === 'category'
|
||||||
|
? '确定要删除这个分类吗?分类下的标签将变为未分类。'
|
||||||
|
: '确定要删除这个标签吗?相关的用户标签关联也会被删除。'}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserTagsTab;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { UserTagsTab } from './UserTagsTab';
|
||||||
|
export { default } from './UserTagsTab';
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { UserTagsTab } from './UserTagsTab';
|
||||||
|
export { AudienceSegmentsTab } from './AudienceSegmentsTab';
|
||||||
|
|
@ -105,4 +105,42 @@ export const API_ENDPOINTS = {
|
||||||
UPDATE: (id: string) => `/v1/admin/notifications/${id}`,
|
UPDATE: (id: string) => `/v1/admin/notifications/${id}`,
|
||||||
DELETE: (id: string) => `/v1/admin/notifications/${id}`,
|
DELETE: (id: string) => `/v1/admin/notifications/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 用户画像 - 标签管理 (admin-service)
|
||||||
|
USER_TAGS: {
|
||||||
|
// 标签分类
|
||||||
|
CATEGORIES: '/v1/admin/tags/categories',
|
||||||
|
CATEGORY_DETAIL: (id: string) => `/v1/admin/tags/categories/${id}`,
|
||||||
|
// 标签
|
||||||
|
LIST: '/v1/admin/tags',
|
||||||
|
CREATE: '/v1/admin/tags',
|
||||||
|
DETAIL: (id: string) => `/v1/admin/tags/${id}`,
|
||||||
|
UPDATE: (id: string) => `/v1/admin/tags/${id}`,
|
||||||
|
DELETE: (id: string) => `/v1/admin/tags/${id}`,
|
||||||
|
ESTIMATE_USERS: (id: string) => `/v1/admin/tags/${id}/estimate-users`,
|
||||||
|
// 用户标签分配
|
||||||
|
ASSIGN: '/v1/admin/tags/assign',
|
||||||
|
REMOVE: '/v1/admin/tags/remove',
|
||||||
|
USER_TAGS: (accountSequence: string) => `/v1/admin/tags/users/${accountSequence}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 用户画像 - 分类规则 (admin-service)
|
||||||
|
CLASSIFICATION_RULES: {
|
||||||
|
LIST: '/v1/admin/classification-rules',
|
||||||
|
CREATE: '/v1/admin/classification-rules',
|
||||||
|
DETAIL: (id: string) => `/v1/admin/classification-rules/${id}`,
|
||||||
|
UPDATE: (id: string) => `/v1/admin/classification-rules/${id}`,
|
||||||
|
DELETE: (id: string) => `/v1/admin/classification-rules/${id}`,
|
||||||
|
EVALUATE: (id: string) => `/v1/admin/classification-rules/${id}/evaluate`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 用户画像 - 人群包 (admin-service)
|
||||||
|
AUDIENCE_SEGMENTS: {
|
||||||
|
LIST: '/v1/admin/audience-segments',
|
||||||
|
CREATE: '/v1/admin/audience-segments',
|
||||||
|
DETAIL: (id: string) => `/v1/admin/audience-segments/${id}`,
|
||||||
|
UPDATE: (id: string) => `/v1/admin/audience-segments/${id}`,
|
||||||
|
DELETE: (id: string) => `/v1/admin/audience-segments/${id}`,
|
||||||
|
REFRESH: (id: string) => `/v1/admin/audience-segments/${id}/refresh`,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* 人群包服务
|
||||||
|
* 负责人群包管理相关的API调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from '@/infrastructure/api/client';
|
||||||
|
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 类型定义
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 条件操作符 */
|
||||||
|
export type ConditionOperator =
|
||||||
|
| 'EQUALS'
|
||||||
|
| 'NOT_EQUALS'
|
||||||
|
| 'CONTAINS'
|
||||||
|
| 'NOT_CONTAINS'
|
||||||
|
| 'GREATER_THAN'
|
||||||
|
| 'LESS_THAN'
|
||||||
|
| 'BETWEEN'
|
||||||
|
| 'IN'
|
||||||
|
| 'NOT_IN'
|
||||||
|
| 'IS_NULL'
|
||||||
|
| 'IS_NOT_NULL'
|
||||||
|
| 'HAS_TAG'
|
||||||
|
| 'NOT_HAS_TAG';
|
||||||
|
|
||||||
|
/** 逻辑操作符 */
|
||||||
|
export type LogicOperator = 'AND' | 'OR';
|
||||||
|
|
||||||
|
/** 人群包状态 */
|
||||||
|
export type SegmentStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
||||||
|
|
||||||
|
/** 条件项 */
|
||||||
|
export interface SegmentCondition {
|
||||||
|
field: string;
|
||||||
|
operator: ConditionOperator;
|
||||||
|
value?: string | number | boolean | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 条件组 */
|
||||||
|
export interface SegmentConditionGroup {
|
||||||
|
logic: LogicOperator;
|
||||||
|
conditions: SegmentCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人群包 */
|
||||||
|
export interface AudienceSegment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
conditionGroups: SegmentConditionGroup[];
|
||||||
|
groupsLogic: LogicOperator;
|
||||||
|
status: SegmentStatus;
|
||||||
|
estimatedUsers: number | null;
|
||||||
|
lastRefreshedAt: string | null;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建人群包请求 */
|
||||||
|
export interface CreateSegmentRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
conditionGroups: SegmentConditionGroup[];
|
||||||
|
groupsLogic?: LogicOperator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新人群包请求 */
|
||||||
|
export interface UpdateSegmentRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
conditionGroups?: SegmentConditionGroup[];
|
||||||
|
groupsLogic?: LogicOperator;
|
||||||
|
status?: SegmentStatus;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人群包列表查询参数 */
|
||||||
|
export interface ListSegmentsParams {
|
||||||
|
status?: SegmentStatus;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 选项常量
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 人群包状态选项 */
|
||||||
|
export const SEGMENT_STATUS_OPTIONS = [
|
||||||
|
{ value: 'DRAFT', label: '草稿', color: 'gray' },
|
||||||
|
{ value: 'ACTIVE', label: '已激活', color: 'green' },
|
||||||
|
{ value: 'ARCHIVED', label: '已归档', color: 'yellow' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 条件操作符选项 */
|
||||||
|
export const CONDITION_OPERATOR_OPTIONS = [
|
||||||
|
{ value: 'EQUALS', label: '等于', group: 'comparison' },
|
||||||
|
{ value: 'NOT_EQUALS', label: '不等于', group: 'comparison' },
|
||||||
|
{ value: 'CONTAINS', label: '包含', group: 'string' },
|
||||||
|
{ value: 'NOT_CONTAINS', label: '不包含', group: 'string' },
|
||||||
|
{ value: 'GREATER_THAN', label: '大于', group: 'number' },
|
||||||
|
{ value: 'LESS_THAN', label: '小于', group: 'number' },
|
||||||
|
{ value: 'BETWEEN', label: '在...之间', group: 'number' },
|
||||||
|
{ value: 'IN', label: '在列表中', group: 'list' },
|
||||||
|
{ value: 'NOT_IN', label: '不在列表中', group: 'list' },
|
||||||
|
{ value: 'IS_NULL', label: '为空', group: 'null' },
|
||||||
|
{ value: 'IS_NOT_NULL', label: '不为空', group: 'null' },
|
||||||
|
{ value: 'HAS_TAG', label: '有标签', group: 'tag' },
|
||||||
|
{ value: 'NOT_HAS_TAG', label: '无标签', group: 'tag' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 可用字段选项 */
|
||||||
|
export const SEGMENT_FIELD_OPTIONS = [
|
||||||
|
// 用户基础属性
|
||||||
|
{ value: 'registrationDays', label: '注册天数', type: 'number' },
|
||||||
|
{ value: 'lastLoginDays', label: '最后登录距今天数', type: 'number' },
|
||||||
|
{ value: 'province', label: '省份', type: 'string' },
|
||||||
|
{ value: 'city', label: '城市', type: 'string' },
|
||||||
|
// 消费行为
|
||||||
|
{ value: 'totalPurchaseAmount', label: '累计消费金额', type: 'number' },
|
||||||
|
{ value: 'purchaseCount', label: '购买次数', type: 'number' },
|
||||||
|
{ value: 'lastPurchaseDays', label: '最后购买距今天数', type: 'number' },
|
||||||
|
{ value: 'averageOrderAmount', label: '平均订单金额', type: 'number' },
|
||||||
|
// 邀请数据
|
||||||
|
{ value: 'totalInvites', label: '累计邀请人数', type: 'number' },
|
||||||
|
{ value: 'directInvites', label: '直接邀请人数', type: 'number' },
|
||||||
|
// 标签
|
||||||
|
{ value: 'tag', label: '用户标签', type: 'tag' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 服务方法
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
export const audienceSegmentService = {
|
||||||
|
/** 获取人群包列表 */
|
||||||
|
async getSegments(params: ListSegmentsParams = {}): Promise<AudienceSegment[]> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.AUDIENCE_SEGMENTS.LIST, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取人群包详情 */
|
||||||
|
async getSegment(id: string): Promise<AudienceSegment> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.AUDIENCE_SEGMENTS.DETAIL(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 创建人群包 */
|
||||||
|
async createSegment(data: CreateSegmentRequest): Promise<AudienceSegment> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.AUDIENCE_SEGMENTS.CREATE, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 更新人群包 */
|
||||||
|
async updateSegment(id: string, data: UpdateSegmentRequest): Promise<AudienceSegment> {
|
||||||
|
return apiClient.put(API_ENDPOINTS.AUDIENCE_SEGMENTS.UPDATE(id), data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除人群包 */
|
||||||
|
async deleteSegment(id: string): Promise<void> {
|
||||||
|
return apiClient.delete(API_ENDPOINTS.AUDIENCE_SEGMENTS.DELETE(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 刷新人群包 (重新计算用户数) */
|
||||||
|
async refreshSegment(id: string): Promise<AudienceSegment> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.AUDIENCE_SEGMENTS.REFRESH(id));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default audienceSegmentService;
|
||||||
|
|
@ -13,7 +13,14 @@ export type NotificationType = 'SYSTEM' | 'ACTIVITY' | 'REWARD' | 'UPGRADE' | 'A
|
||||||
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||||
|
|
||||||
/** 目标用户类型 */
|
/** 目标用户类型 */
|
||||||
export type TargetType = 'ALL' | 'NEW_USER' | 'VIP';
|
export type TargetType = 'ALL' | 'BY_TAG' | 'SPECIFIC';
|
||||||
|
|
||||||
|
/** 目标配置 */
|
||||||
|
export interface TargetConfig {
|
||||||
|
type: TargetType;
|
||||||
|
tagIds?: string[];
|
||||||
|
accountSequences?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 通知项 */
|
/** 通知项 */
|
||||||
export interface NotificationItem {
|
export interface NotificationItem {
|
||||||
|
|
@ -23,14 +30,13 @@ export interface NotificationItem {
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
priority: NotificationPriority;
|
priority: NotificationPriority;
|
||||||
targetType: TargetType;
|
targetType: TargetType;
|
||||||
|
targetConfig: TargetConfig | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
linkUrl: string | null;
|
linkUrl: string | null;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
|
||||||
createdBy: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建通知请求 */
|
/** 创建通知请求 */
|
||||||
|
|
@ -40,6 +46,10 @@ export interface CreateNotificationRequest {
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
priority?: NotificationPriority;
|
priority?: NotificationPriority;
|
||||||
targetType?: TargetType;
|
targetType?: TargetType;
|
||||||
|
targetConfig?: {
|
||||||
|
tagIds?: string[];
|
||||||
|
accountSequences?: string[];
|
||||||
|
};
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
linkUrl?: string;
|
linkUrl?: string;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
|
|
@ -53,6 +63,10 @@ export interface UpdateNotificationRequest {
|
||||||
type?: NotificationType;
|
type?: NotificationType;
|
||||||
priority?: NotificationPriority;
|
priority?: NotificationPriority;
|
||||||
targetType?: TargetType;
|
targetType?: TargetType;
|
||||||
|
targetConfig?: {
|
||||||
|
tagIds?: string[];
|
||||||
|
accountSequences?: string[];
|
||||||
|
};
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
linkUrl?: string | null;
|
linkUrl?: string | null;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
|
@ -86,9 +100,9 @@ export const NOTIFICATION_PRIORITY_OPTIONS = [
|
||||||
|
|
||||||
/** 目标用户类型选项 */
|
/** 目标用户类型选项 */
|
||||||
export const TARGET_TYPE_OPTIONS = [
|
export const TARGET_TYPE_OPTIONS = [
|
||||||
{ value: 'ALL', label: '全部用户' },
|
{ value: 'ALL', label: '全部用户', description: '发送给所有用户' },
|
||||||
{ value: 'NEW_USER', label: '新用户' },
|
{ value: 'BY_TAG', label: '按标签筛选', description: '发送给有指定标签的用户' },
|
||||||
{ value: 'VIP', label: 'VIP用户' },
|
{ value: 'SPECIFIC', label: '指定用户', description: '发送给特定用户列表' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
/**
|
||||||
|
* 用户标签服务
|
||||||
|
* 负责用户画像相关的API调用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from '@/infrastructure/api/client';
|
||||||
|
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 类型定义
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 标签类型 */
|
||||||
|
export type TagType = 'MANUAL' | 'AUTO' | 'COMPUTED' | 'SYSTEM';
|
||||||
|
|
||||||
|
/** 标签值类型 */
|
||||||
|
export type TagValueType = 'BOOLEAN' | 'ENUM' | 'NUMBER' | 'STRING';
|
||||||
|
|
||||||
|
/** 标签分类 */
|
||||||
|
export interface TagCategory {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户标签 */
|
||||||
|
export interface UserTag {
|
||||||
|
id: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
categoryName?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户标签分配 */
|
||||||
|
export interface UserTagAssignment {
|
||||||
|
id: string;
|
||||||
|
accountSequence: string;
|
||||||
|
tagId: string;
|
||||||
|
tagCode: string;
|
||||||
|
tagName: string;
|
||||||
|
value: string | null;
|
||||||
|
source: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
assignedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建标签分类请求 */
|
||||||
|
export interface CreateCategoryRequest {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新标签分类请求 */
|
||||||
|
export interface UpdateCategoryRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建标签请求 */
|
||||||
|
export interface CreateTagRequest {
|
||||||
|
categoryId?: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
type?: TagType;
|
||||||
|
valueType?: TagValueType;
|
||||||
|
enumValues?: string[];
|
||||||
|
isAdvertisable?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新标签请求 */
|
||||||
|
export interface UpdateTagRequest {
|
||||||
|
categoryId?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
type?: TagType;
|
||||||
|
valueType?: TagValueType;
|
||||||
|
enumValues?: string[];
|
||||||
|
isAdvertisable?: boolean;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标签列表查询参数 */
|
||||||
|
export interface ListTagsParams {
|
||||||
|
categoryId?: string;
|
||||||
|
type?: TagType;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
isAdvertisable?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分配标签请求 */
|
||||||
|
export interface AssignTagRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
tagId: string;
|
||||||
|
value?: string;
|
||||||
|
source?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除标签请求 */
|
||||||
|
export interface RemoveTagRequest {
|
||||||
|
accountSequence: string;
|
||||||
|
tagId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 选项常量
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 标签类型选项 */
|
||||||
|
export const TAG_TYPE_OPTIONS = [
|
||||||
|
{ value: 'MANUAL', label: '手动标签', color: 'blue' },
|
||||||
|
{ value: 'AUTO', label: '自动标签', color: 'green' },
|
||||||
|
{ value: 'COMPUTED', label: '计算标签', color: 'purple' },
|
||||||
|
{ value: 'SYSTEM', label: '系统标签', color: 'gray' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 标签值类型选项 */
|
||||||
|
export const TAG_VALUE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'BOOLEAN', label: '布尔型' },
|
||||||
|
{ value: 'ENUM', label: '枚举型' },
|
||||||
|
{ value: 'NUMBER', label: '数值型' },
|
||||||
|
{ value: 'STRING', label: '字符串' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 预设颜色 */
|
||||||
|
export const TAG_COLORS = [
|
||||||
|
'#EF4444', // red
|
||||||
|
'#F97316', // orange
|
||||||
|
'#EAB308', // yellow
|
||||||
|
'#22C55E', // green
|
||||||
|
'#14B8A6', // teal
|
||||||
|
'#3B82F6', // blue
|
||||||
|
'#8B5CF6', // purple
|
||||||
|
'#EC4899', // pink
|
||||||
|
'#6B7280', // gray
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 服务方法
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
export const userTagService = {
|
||||||
|
// =====================
|
||||||
|
// 标签分类
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 获取所有标签分类 */
|
||||||
|
async getCategories(): Promise<TagCategory[]> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USER_TAGS.CATEGORIES);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 创建标签分类 */
|
||||||
|
async createCategory(data: CreateCategoryRequest): Promise<TagCategory> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.USER_TAGS.CATEGORIES, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 更新标签分类 */
|
||||||
|
async updateCategory(id: string, data: UpdateCategoryRequest): Promise<TagCategory> {
|
||||||
|
return apiClient.put(API_ENDPOINTS.USER_TAGS.CATEGORY_DETAIL(id), data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除标签分类 */
|
||||||
|
async deleteCategory(id: string): Promise<void> {
|
||||||
|
return apiClient.delete(API_ENDPOINTS.USER_TAGS.CATEGORY_DETAIL(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 标签
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 获取标签列表 */
|
||||||
|
async getTags(params: ListTagsParams = {}): Promise<UserTag[]> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USER_TAGS.LIST, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取标签详情 */
|
||||||
|
async getTag(id: string): Promise<UserTag> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USER_TAGS.DETAIL(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 创建标签 */
|
||||||
|
async createTag(data: CreateTagRequest): Promise<UserTag> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.USER_TAGS.CREATE, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 更新标签 */
|
||||||
|
async updateTag(id: string, data: UpdateTagRequest): Promise<UserTag> {
|
||||||
|
return apiClient.put(API_ENDPOINTS.USER_TAGS.UPDATE(id), data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除标签 */
|
||||||
|
async deleteTag(id: string): Promise<void> {
|
||||||
|
return apiClient.delete(API_ENDPOINTS.USER_TAGS.DELETE(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 预估标签用户数 */
|
||||||
|
async estimateUsers(id: string): Promise<{ count: number }> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USER_TAGS.ESTIMATE_USERS(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// 用户标签分配
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/** 给用户分配标签 */
|
||||||
|
async assignTag(data: AssignTagRequest): Promise<{ success: boolean }> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.USER_TAGS.ASSIGN, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 移除用户标签 */
|
||||||
|
async removeTag(data: RemoveTagRequest): Promise<{ success: boolean }> {
|
||||||
|
return apiClient.post(API_ENDPOINTS.USER_TAGS.REMOVE, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 获取用户的标签 */
|
||||||
|
async getUserTags(accountSequence: string): Promise<UserTagAssignment[]> {
|
||||||
|
return apiClient.get(API_ENDPOINTS.USER_TAGS.USER_TAGS(accountSequence));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default userTagService;
|
||||||
Loading…
Reference in New Issue