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/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@prisma/client": "^5.7.0",
|
||||
|
|
@ -1888,6 +1889,33 @@
|
|||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "3.2.1",
|
||||
"uuid": "11.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule/node_modules/uuid": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||
|
|
@ -2451,6 +2479,12 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
|
|
@ -4244,6 +4278,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
|
||||
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.4.0",
|
||||
"luxon": "~3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -7314,6 +7358,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/swagger": "^7.1.17",
|
||||
"@prisma/client": "^5.7.0",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ model Notification {
|
|||
type NotificationType // 通知类型
|
||||
priority NotificationPriority @default(NORMAL) // 优先级
|
||||
targetType TargetType @default(ALL) // 目标用户类型
|
||||
targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑
|
||||
imageUrl String? // 可选的图片URL
|
||||
linkUrl String? // 可选的跳转链接
|
||||
isEnabled Boolean @default(true) // 是否启用
|
||||
|
|
@ -65,11 +66,14 @@ model Notification {
|
|||
updatedAt DateTime @updatedAt
|
||||
createdBy String // 创建人ID
|
||||
|
||||
// 用户已读记录
|
||||
// 关联
|
||||
readRecords NotificationRead[]
|
||||
targetTags NotificationTagTarget[] // BY_TAG 时使用
|
||||
targetUsers NotificationUserTarget[] // SPECIFIC 时使用
|
||||
|
||||
@@index([isEnabled, publishedAt])
|
||||
@@index([type])
|
||||
@@index([targetType])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
|
|
@ -106,9 +110,312 @@ enum NotificationPriority {
|
|||
|
||||
/// 目标用户类型
|
||||
enum TargetType {
|
||||
ALL // 所有用户
|
||||
NEW_USER // 新用户
|
||||
VIP // VIP用户
|
||||
ALL // 所有用户
|
||||
BY_TAG // 按标签匹配
|
||||
SPECIFIC // 指定用户列表
|
||||
}
|
||||
|
||||
/// 多标签匹配逻辑
|
||||
enum TargetLogic {
|
||||
ANY // 匹配任一标签
|
||||
ALL // 匹配所有标签
|
||||
}
|
||||
|
||||
/// 通知-标签关联
|
||||
model NotificationTagTarget {
|
||||
id String @id @default(uuid())
|
||||
notificationId String @map("notification_id")
|
||||
tagId String @map("tag_id")
|
||||
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
tag UserTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([notificationId, tagId])
|
||||
@@index([tagId])
|
||||
@@map("notification_tag_targets")
|
||||
}
|
||||
|
||||
/// 通知-用户关联 (指定用户)
|
||||
model NotificationUserTarget {
|
||||
id String @id @default(uuid())
|
||||
notificationId String @map("notification_id")
|
||||
accountSequence String @map("account_sequence") @db.VarChar(12)
|
||||
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([notificationId, accountSequence])
|
||||
@@index([accountSequence])
|
||||
@@map("notification_user_targets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Profile System (用户画像系统) - 面向通知 + 广告
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 标签分类 (Tag Category) - 标签的分组管理
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 标签分类
|
||||
model TagCategory {
|
||||
id String @id @default(uuid())
|
||||
code String @unique @db.VarChar(50) // "lifecycle", "value", "behavior"
|
||||
name String @db.VarChar(100) // "生命周期", "价值分层", "行为特征"
|
||||
description String? @db.Text
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tags UserTag[]
|
||||
|
||||
@@index([code])
|
||||
@@index([isEnabled])
|
||||
@@map("tag_categories")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 用户标签 (User Tag) - 增强版
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 标签类型
|
||||
enum TagType {
|
||||
MANUAL // 手动打标 (管理员操作)
|
||||
AUTO // 自动打标 (规则驱动)
|
||||
COMPUTED // 计算型 (实时计算,不存储关联)
|
||||
SYSTEM // 系统内置 (不可删除)
|
||||
}
|
||||
|
||||
/// 标签值类型
|
||||
enum TagValueType {
|
||||
BOOLEAN // 布尔型: 有/无
|
||||
ENUM // 枚举型: 高/中/低
|
||||
NUMBER // 数值型: 0-100分
|
||||
STRING // 字符串型
|
||||
}
|
||||
|
||||
/// 用户标签定义
|
||||
model UserTag {
|
||||
id String @id @default(uuid())
|
||||
categoryId String? @map("category_id")
|
||||
code String @unique @db.VarChar(50) // "vip", "new_user", "whale"
|
||||
name String @db.VarChar(100) // "VIP用户", "新用户", "大客户"
|
||||
description String? @db.Text
|
||||
color String? @db.VarChar(20) // "#FF5722"
|
||||
|
||||
type TagType @default(MANUAL) // 标签类型
|
||||
valueType TagValueType @default(BOOLEAN) @map("value_type") // 标签值类型
|
||||
|
||||
// 枚举型标签的可选值
|
||||
// 例如: ["高", "中", "低"] 或 ["活跃", "沉默", "流失"]
|
||||
enumValues Json? @map("enum_values")
|
||||
|
||||
// 关联的自动规则 (type=AUTO 时使用)
|
||||
ruleId String? @unique @map("rule_id")
|
||||
rule UserClassificationRule? @relation(fields: [ruleId], references: [id], onDelete: SetNull)
|
||||
|
||||
// 广告相关
|
||||
isAdvertisable Boolean @default(true) @map("is_advertisable") // 是否可用于广告定向
|
||||
estimatedUsers Int? @map("estimated_users") // 预估覆盖用户数
|
||||
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
category TagCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
assignments UserTagAssignment[]
|
||||
notifications NotificationTagTarget[]
|
||||
|
||||
@@index([categoryId])
|
||||
@@index([code])
|
||||
@@index([type])
|
||||
@@index([isEnabled])
|
||||
@@index([isAdvertisable])
|
||||
@@map("user_tags")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 用户-标签关联 - 支持标签值
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 用户-标签关联
|
||||
model UserTagAssignment {
|
||||
id String @id @default(uuid())
|
||||
accountSequence String @map("account_sequence") @db.VarChar(12)
|
||||
tagId String @map("tag_id")
|
||||
|
||||
// 标签值 (根据 valueType)
|
||||
// BOOLEAN: null (存在即为true)
|
||||
// ENUM: "高" / "中" / "低"
|
||||
// NUMBER: "85" (字符串存储)
|
||||
// STRING: 任意字符串
|
||||
value String? @db.VarChar(100)
|
||||
|
||||
assignedAt DateTime @default(now()) @map("assigned_at")
|
||||
assignedBy String? @map("assigned_by") // null=系统自动, 否则为管理员ID
|
||||
expiresAt DateTime? @map("expires_at") // 可选过期时间
|
||||
source String? @db.VarChar(50) // 来源: "rule:xxx", "import", "manual", "kafka"
|
||||
|
||||
tag UserTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([accountSequence, tagId])
|
||||
@@index([accountSequence])
|
||||
@@index([tagId])
|
||||
@@index([value])
|
||||
@@index([expiresAt])
|
||||
@@map("user_tag_assignments")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 用户分类规则 (Classification Rule)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 用户分类规则
|
||||
model UserClassificationRule {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(100) // "30天内新用户"
|
||||
description String? @db.Text
|
||||
|
||||
// 规则条件 (JSON)
|
||||
// 示例:
|
||||
// {
|
||||
// "type": "AND",
|
||||
// "rules": [
|
||||
// { "field": "registeredAt", "operator": "within_days", "value": 30 },
|
||||
// { "field": "kycStatus", "operator": "eq", "value": "VERIFIED" }
|
||||
// ]
|
||||
// }
|
||||
conditions Json
|
||||
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联的自动标签
|
||||
tag UserTag?
|
||||
|
||||
@@map("user_classification_rules")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 用户特征 (User Features) - 数值型指标
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 用户特征 - 计算后的用户画像指标
|
||||
model UserFeature {
|
||||
id String @id @default(uuid())
|
||||
accountSequence String @unique @map("account_sequence") @db.VarChar(12)
|
||||
|
||||
// RFM 模型
|
||||
rfmRecency Int? @map("rfm_recency") // 最近一次活跃距今天数
|
||||
rfmFrequency Int? @map("rfm_frequency") // 过去30天活跃天数
|
||||
rfmMonetary Decimal? @map("rfm_monetary") @db.Decimal(18, 2) // 累计消费金额
|
||||
rfmScore Int? @map("rfm_score") // RFM综合分 (0-100)
|
||||
|
||||
// 活跃度
|
||||
activeLevel String? @map("active_level") @db.VarChar(20) // "高活跃", "中活跃", "低活跃", "沉默"
|
||||
lastActiveAt DateTime? @map("last_active_at")
|
||||
|
||||
// 价值分层
|
||||
valueLevel String? @map("value_level") @db.VarChar(20) // "高价值", "中价值", "低价值", "潜力"
|
||||
lifetimeValue Decimal? @map("lifetime_value") @db.Decimal(18, 2) // 用户生命周期价值 LTV
|
||||
|
||||
// 生命周期
|
||||
lifecycleStage String? @map("lifecycle_stage") @db.VarChar(20) // "新用户", "成长期", "成熟期", "衰退期", "流失"
|
||||
|
||||
// 自定义特征 (JSON扩展)
|
||||
customFeatures Json? @map("custom_features")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([rfmScore])
|
||||
@@index([activeLevel])
|
||||
@@index([valueLevel])
|
||||
@@index([lifecycleStage])
|
||||
@@map("user_features")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 人群包 (Audience Segment) - 复杂定向
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 人群包用途
|
||||
enum SegmentUsageType {
|
||||
GENERAL // 通用
|
||||
NOTIFICATION // 通知定向
|
||||
ADVERTISING // 广告定向
|
||||
ANALYTICS // 数据分析
|
||||
}
|
||||
|
||||
/// 人群包 - 多条件组合的用户群
|
||||
model AudienceSegment {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(100) // "高价值活跃用户"
|
||||
description String? @db.Text
|
||||
|
||||
// 定向条件 (JSON)
|
||||
// {
|
||||
// "type": "AND",
|
||||
// "conditions": [
|
||||
// { "type": "tag", "tagCode": "vip", "operator": "eq", "value": true },
|
||||
// { "type": "feature", "field": "rfmScore", "operator": "gte", "value": 80 },
|
||||
// { "type": "profile", "field": "province", "operator": "in", "value": ["广东", "浙江"] }
|
||||
// ]
|
||||
// }
|
||||
conditions Json
|
||||
|
||||
// 预估数据
|
||||
estimatedUsers Int? @map("estimated_users")
|
||||
lastCalculated DateTime? @map("last_calculated")
|
||||
|
||||
// 用途
|
||||
usageType SegmentUsageType @default(GENERAL) @map("usage_type")
|
||||
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String @map("created_by")
|
||||
|
||||
@@index([usageType])
|
||||
@@index([isEnabled])
|
||||
@@map("audience_segments")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 标签变更日志 (审计追踪)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// 标签变更操作
|
||||
enum TagAction {
|
||||
ASSIGN // 打标签
|
||||
UPDATE // 更新标签值
|
||||
REMOVE // 移除标签
|
||||
EXPIRE // 过期移除
|
||||
}
|
||||
|
||||
/// 标签变更日志
|
||||
model UserTagLog {
|
||||
id String @id @default(uuid())
|
||||
accountSequence String @map("account_sequence") @db.VarChar(12)
|
||||
tagCode String @map("tag_code") @db.VarChar(50)
|
||||
|
||||
action TagAction
|
||||
oldValue String? @map("old_value") @db.VarChar(100)
|
||||
newValue String? @map("new_value") @db.VarChar(100)
|
||||
|
||||
reason String? @db.VarChar(200) // "规则触发", "管理员操作", "导入", "过期清理"
|
||||
operatorId String? @map("operator_id")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([accountSequence, createdAt])
|
||||
@@index([tagCode])
|
||||
@@index([action])
|
||||
@@index([createdAt])
|
||||
@@map("user_tag_logs")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
NOTIFICATION_REPOSITORY,
|
||||
NotificationRepository,
|
||||
} from '../../domain/repositories/notification.repository';
|
||||
import { NotificationEntity } from '../../domain/entities/notification.entity';
|
||||
import {
|
||||
NotificationEntity,
|
||||
NotificationTarget,
|
||||
TargetType,
|
||||
} from '../../domain/entities/notification.entity';
|
||||
import {
|
||||
CreateNotificationDto,
|
||||
UpdateNotificationDto,
|
||||
|
|
@ -46,13 +51,27 @@ export class AdminNotificationController {
|
|||
*/
|
||||
@Post()
|
||||
async create(@Body() dto: CreateNotificationDto): Promise<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({
|
||||
id: uuidv4(),
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
type: dto.type,
|
||||
priority: dto.priority,
|
||||
targetType: dto.targetType,
|
||||
targetType,
|
||||
targetConfig,
|
||||
imageUrl: dto.imageUrl,
|
||||
linkUrl: dto.linkUrl,
|
||||
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
||||
|
|
@ -71,7 +90,7 @@ export class AdminNotificationController {
|
|||
async findOne(@Param('id') id: string): Promise<NotificationResponseDto> {
|
||||
const notification = await this.notificationRepo.findById(id);
|
||||
if (!notification) {
|
||||
throw new Error('Notification not found');
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
return NotificationResponseDto.fromEntity(notification);
|
||||
}
|
||||
|
|
@ -99,33 +118,48 @@ export class AdminNotificationController {
|
|||
): Promise<NotificationResponseDto> {
|
||||
const existing = await this.notificationRepo.findById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Notification not found');
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
|
||||
const updated = new NotificationEntity(
|
||||
existing.id,
|
||||
dto.title ?? existing.title,
|
||||
dto.content ?? existing.content,
|
||||
dto.type ?? existing.type,
|
||||
dto.priority ?? existing.priority,
|
||||
dto.targetType ?? existing.targetType,
|
||||
dto.imageUrl !== undefined ? dto.imageUrl : existing.imageUrl,
|
||||
dto.linkUrl !== undefined ? dto.linkUrl : existing.linkUrl,
|
||||
dto.isEnabled ?? existing.isEnabled,
|
||||
dto.publishedAt !== undefined
|
||||
// 构建目标配置
|
||||
let targetConfig: NotificationTarget | null | undefined = undefined;
|
||||
if (dto.targetConfig !== undefined || dto.targetType !== undefined) {
|
||||
const targetType = dto.targetType ?? existing.targetType;
|
||||
if (dto.targetConfig || targetType !== TargetType.ALL) {
|
||||
targetConfig = {
|
||||
type: targetType,
|
||||
tagIds: dto.targetConfig?.tagIds ?? existing.targetConfig?.tagIds,
|
||||
segmentId: dto.targetConfig?.segmentId ?? existing.targetConfig?.segmentId,
|
||||
accountSequences:
|
||||
dto.targetConfig?.accountSequences ??
|
||||
existing.targetConfig?.accountSequences,
|
||||
};
|
||||
} else {
|
||||
targetConfig = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = existing.update({
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
type: dto.type,
|
||||
priority: dto.priority,
|
||||
targetType: dto.targetType,
|
||||
targetConfig,
|
||||
imageUrl: dto.imageUrl,
|
||||
linkUrl: dto.linkUrl,
|
||||
isEnabled: dto.isEnabled,
|
||||
publishedAt: dto.publishedAt !== undefined
|
||||
? dto.publishedAt
|
||||
? new Date(dto.publishedAt)
|
||||
: null
|
||||
: existing.publishedAt,
|
||||
dto.expiresAt !== undefined
|
||||
: undefined,
|
||||
expiresAt: dto.expiresAt !== undefined
|
||||
? dto.expiresAt
|
||||
? new Date(dto.expiresAt)
|
||||
: null
|
||||
: existing.expiresAt,
|
||||
existing.createdAt,
|
||||
new Date(),
|
||||
existing.createdBy,
|
||||
);
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const saved = await this.notificationRepo.save(updated);
|
||||
return NotificationResponseDto.fromEntity(saved);
|
||||
|
|
|
|||
|
|
@ -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 { NotificationType, NotificationPriority, TargetType } from '../../../domain/entities/notification.entity';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsArray,
|
||||
Min,
|
||||
Max,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
TargetType,
|
||||
} from '../../../domain/entities/notification.entity';
|
||||
|
||||
/**
|
||||
* 目标配置 DTO
|
||||
*/
|
||||
export class TargetConfigDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tagIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
segmentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
accountSequences?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知请求
|
||||
|
|
@ -22,6 +57,11 @@ export class CreateNotificationDto {
|
|||
@IsEnum(TargetType)
|
||||
targetType?: TargetType;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => TargetConfigDto)
|
||||
targetConfig?: TargetConfigDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
|
@ -63,6 +103,11 @@ export class UpdateNotificationDto {
|
|||
@IsEnum(TargetType)
|
||||
targetType?: TargetType;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => TargetConfigDto)
|
||||
targetConfig?: TargetConfigDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
* 目标配置响应
|
||||
*/
|
||||
export interface TargetConfigResponseDto {
|
||||
type: TargetType;
|
||||
tagIds?: string[];
|
||||
segmentId?: string;
|
||||
accountSequences?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知响应DTO
|
||||
*/
|
||||
|
|
@ -11,6 +27,7 @@ export class NotificationResponseDto {
|
|||
type: NotificationType;
|
||||
priority: NotificationPriority;
|
||||
targetType: TargetType;
|
||||
targetConfig: TargetConfigResponseDto | null;
|
||||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
isEnabled: boolean;
|
||||
|
|
@ -26,6 +43,7 @@ export class NotificationResponseDto {
|
|||
type: entity.type,
|
||||
priority: entity.priority,
|
||||
targetType: entity.targetType,
|
||||
targetConfig: entity.targetConfig,
|
||||
imageUrl: entity.imageUrl,
|
||||
linkUrl: entity.linkUrl,
|
||||
isEnabled: entity.isEnabled,
|
||||
|
|
|
|||
|
|
@ -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 { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { join } from 'path';
|
||||
import { configurations } from './config';
|
||||
import { PrismaService } from './infrastructure/persistence/prisma/prisma.service';
|
||||
|
|
@ -35,6 +36,20 @@ import { UserEventConsumerService } from './infrastructure/kafka/user-event-cons
|
|||
import { SystemConfigRepositoryImpl } from './infrastructure/persistence/repositories/system-config.repository.impl';
|
||||
import { SYSTEM_CONFIG_REPOSITORY } from './domain/repositories/system-config.repository';
|
||||
import { AdminSystemConfigController, PublicSystemConfigController } from './api/controllers/system-config.controller';
|
||||
// User Profile System imports (标签、规则、人群包)
|
||||
import { USER_TAG_REPOSITORY } from './domain/repositories/user-tag.repository';
|
||||
import { UserTagRepositoryImpl } from './infrastructure/persistence/repositories/user-tag.repository.impl';
|
||||
import { CLASSIFICATION_RULE_REPOSITORY } from './domain/repositories/classification-rule.repository';
|
||||
import { ClassificationRuleRepositoryImpl } from './infrastructure/persistence/repositories/classification-rule.repository.impl';
|
||||
import { AUDIENCE_SEGMENT_REPOSITORY } from './domain/repositories/audience-segment.repository';
|
||||
import { AudienceSegmentRepositoryImpl } from './infrastructure/persistence/repositories/audience-segment.repository.impl';
|
||||
import { RuleEngineService } from './application/services/rule-engine.service';
|
||||
import { UserTaggingService } from './application/services/user-tagging.service';
|
||||
import { AudienceSegmentService } from './application/services/audience-segment.service';
|
||||
import { UserTagController } from './api/controllers/user-tag.controller';
|
||||
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -47,6 +62,8 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
|||
rootPath: join(process.cwd(), process.env.UPLOAD_DIR || 'uploads'),
|
||||
serveRoot: '/uploads',
|
||||
}),
|
||||
// Schedule module for cron jobs
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [
|
||||
VersionController,
|
||||
|
|
@ -58,6 +75,10 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
|||
UserController,
|
||||
AdminSystemConfigController,
|
||||
PublicSystemConfigController,
|
||||
// User Profile System Controllers
|
||||
UserTagController,
|
||||
ClassificationRuleController,
|
||||
AudienceSegmentController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -95,6 +116,24 @@ import { AdminSystemConfigController, PublicSystemConfigController } from './api
|
|||
provide: SYSTEM_CONFIG_REPOSITORY,
|
||||
useClass: SystemConfigRepositoryImpl,
|
||||
},
|
||||
// User Profile System (标签、规则、人群包)
|
||||
{
|
||||
provide: USER_TAG_REPOSITORY,
|
||||
useClass: UserTagRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: CLASSIFICATION_RULE_REPOSITORY,
|
||||
useClass: ClassificationRuleRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: AUDIENCE_SEGMENT_REPOSITORY,
|
||||
useClass: AudienceSegmentRepositoryImpl,
|
||||
},
|
||||
RuleEngineService,
|
||||
UserTaggingService,
|
||||
AudienceSegmentService,
|
||||
// Scheduled Jobs
|
||||
AutoTagSyncJob,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -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 priority: NotificationPriority,
|
||||
public readonly targetType: TargetType,
|
||||
public readonly targetConfig: NotificationTarget | null,
|
||||
public readonly imageUrl: string | null,
|
||||
public readonly linkUrl: string | null,
|
||||
public readonly isEnabled: boolean,
|
||||
|
|
@ -46,6 +57,35 @@ export class NotificationEntity {
|
|||
return this.expiresAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为定向通知
|
||||
*/
|
||||
isTargeted(): boolean {
|
||||
return this.targetType !== TargetType.ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要标签过滤
|
||||
*/
|
||||
requiresTagFilter(): boolean {
|
||||
return (
|
||||
this.targetType === TargetType.BY_TAG &&
|
||||
this.targetConfig?.tagIds !== undefined &&
|
||||
this.targetConfig.tagIds.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为特定用户通知
|
||||
*/
|
||||
isSpecificUsers(): boolean {
|
||||
return (
|
||||
this.targetType === TargetType.SPECIFIC &&
|
||||
this.targetConfig?.accountSequences !== undefined &&
|
||||
this.targetConfig.accountSequences.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新通知
|
||||
*/
|
||||
|
|
@ -56,6 +96,7 @@ export class NotificationEntity {
|
|||
type: NotificationType;
|
||||
priority?: NotificationPriority;
|
||||
targetType?: TargetType;
|
||||
targetConfig?: NotificationTarget | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
publishedAt?: Date | null;
|
||||
|
|
@ -63,13 +104,27 @@ export class NotificationEntity {
|
|||
createdBy: string;
|
||||
}): NotificationEntity {
|
||||
const now = new Date();
|
||||
const targetType = params.targetType ?? TargetType.ALL;
|
||||
|
||||
// 构建目标配置
|
||||
let targetConfig: NotificationTarget | null = null;
|
||||
if (params.targetConfig) {
|
||||
targetConfig = {
|
||||
...params.targetConfig,
|
||||
type: targetType,
|
||||
};
|
||||
} else if (targetType !== TargetType.ALL) {
|
||||
targetConfig = { type: targetType };
|
||||
}
|
||||
|
||||
return new NotificationEntity(
|
||||
params.id,
|
||||
params.title,
|
||||
params.content,
|
||||
params.type,
|
||||
params.priority ?? NotificationPriority.NORMAL,
|
||||
params.targetType ?? TargetType.ALL,
|
||||
targetType,
|
||||
targetConfig,
|
||||
params.imageUrl ?? null,
|
||||
params.linkUrl ?? null,
|
||||
true,
|
||||
|
|
@ -80,6 +135,41 @@ export class NotificationEntity {
|
|||
params.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通知
|
||||
*/
|
||||
update(params: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
type?: NotificationType;
|
||||
priority?: NotificationPriority;
|
||||
targetType?: TargetType;
|
||||
targetConfig?: NotificationTarget | null;
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
isEnabled?: boolean;
|
||||
publishedAt?: Date | null;
|
||||
expiresAt?: Date | null;
|
||||
}): NotificationEntity {
|
||||
return new NotificationEntity(
|
||||
this.id,
|
||||
params.title ?? this.title,
|
||||
params.content ?? this.content,
|
||||
params.type ?? this.type,
|
||||
params.priority ?? this.priority,
|
||||
params.targetType ?? this.targetType,
|
||||
params.targetConfig !== undefined ? params.targetConfig : this.targetConfig,
|
||||
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
|
||||
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
|
||||
params.isEnabled ?? this.isEnabled,
|
||||
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
|
||||
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
|
||||
this.createdAt,
|
||||
new Date(),
|
||||
this.createdBy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -105,9 +195,10 @@ export enum NotificationPriority {
|
|||
|
||||
/**
|
||||
* 目标用户类型
|
||||
* 注意:与 Prisma schema 中的 TargetType 枚举保持一致
|
||||
*/
|
||||
export enum TargetType {
|
||||
ALL = 'ALL',
|
||||
NEW_USER = 'NEW_USER',
|
||||
VIP = 'VIP',
|
||||
ALL = 'ALL', // 所有用户
|
||||
BY_TAG = 'BY_TAG', // 按标签筛选
|
||||
SPECIFIC = 'SPECIFIC', // 特定用户列表
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
NotificationPriority,
|
||||
TargetType,
|
||||
NotificationTarget,
|
||||
} from '../../../domain/entities/notification.entity';
|
||||
|
||||
/**
|
||||
* 带关联数据的通知
|
||||
*/
|
||||
export interface NotificationWithTargets extends PrismaNotification {
|
||||
targetTags?: { tagId: string }[];
|
||||
targetUsers?: { accountSequence: string }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationMapper {
|
||||
toDomain(prisma: PrismaNotification): NotificationEntity {
|
||||
toDomain(prisma: NotificationWithTargets): NotificationEntity {
|
||||
// 构建目标配置
|
||||
let targetConfig: NotificationTarget | null = null;
|
||||
const targetType = prisma.targetType as TargetType;
|
||||
|
||||
if (targetType === TargetType.BY_TAG && prisma.targetTags?.length) {
|
||||
targetConfig = {
|
||||
type: targetType,
|
||||
tagIds: prisma.targetTags.map((t) => t.tagId),
|
||||
};
|
||||
} else if (
|
||||
targetType === TargetType.SPECIFIC &&
|
||||
prisma.targetUsers?.length
|
||||
) {
|
||||
targetConfig = {
|
||||
type: targetType,
|
||||
accountSequences: prisma.targetUsers.map((u) => u.accountSequence),
|
||||
};
|
||||
} else if (targetType !== TargetType.ALL) {
|
||||
targetConfig = { type: targetType };
|
||||
}
|
||||
|
||||
return new NotificationEntity(
|
||||
prisma.id,
|
||||
prisma.title,
|
||||
prisma.content,
|
||||
prisma.type as NotificationType,
|
||||
prisma.priority as NotificationPriority,
|
||||
prisma.targetType as TargetType,
|
||||
targetType,
|
||||
targetConfig,
|
||||
prisma.imageUrl,
|
||||
prisma.linkUrl,
|
||||
prisma.isEnabled,
|
||||
|
|
@ -33,7 +64,9 @@ export class NotificationMapper {
|
|||
);
|
||||
}
|
||||
|
||||
toPersistence(entity: NotificationEntity): Omit<PrismaNotification, 'id'> & { id: string } {
|
||||
toPersistence(
|
||||
entity: NotificationEntity,
|
||||
): Omit<PrismaNotification, 'id'> & { id: string } {
|
||||
return {
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
|
|
@ -41,6 +74,7 @@ export class NotificationMapper {
|
|||
type: entity.type as PrismaNotificationType,
|
||||
priority: entity.priority as PrismaPriority,
|
||||
targetType: entity.targetType as PrismaTargetType,
|
||||
targetLogic: 'ANY', // 默认 ANY,后续可扩展
|
||||
imageUrl: entity.imageUrl,
|
||||
linkUrl: entity.linkUrl,
|
||||
isEnabled: entity.isEnabled,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
NotificationRepository,
|
||||
|
|
@ -7,31 +7,100 @@ import {
|
|||
import {
|
||||
NotificationEntity,
|
||||
NotificationType,
|
||||
TargetType,
|
||||
} from '../../../domain/entities/notification.entity';
|
||||
import { NotificationMapper } from '../mappers/notification.mapper';
|
||||
import {
|
||||
NotificationMapper,
|
||||
NotificationWithTargets,
|
||||
} from '../mappers/notification.mapper';
|
||||
import {
|
||||
USER_TAG_REPOSITORY,
|
||||
UserTagRepository,
|
||||
} from '../../../domain/repositories/user-tag.repository';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationRepositoryImpl implements NotificationRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: NotificationMapper,
|
||||
@Inject(USER_TAG_REPOSITORY)
|
||||
private readonly tagRepository: UserTagRepository,
|
||||
) {}
|
||||
|
||||
async save(notification: NotificationEntity): Promise<NotificationEntity> {
|
||||
const data = this.mapper.toPersistence(notification);
|
||||
const saved = await this.prisma.notification.upsert({
|
||||
where: { id: notification.id },
|
||||
create: data,
|
||||
update: data,
|
||||
|
||||
// 使用事务保存通知和关联数据
|
||||
const saved = await this.prisma.$transaction(async (tx) => {
|
||||
// 保存通知主体
|
||||
const result = await tx.notification.upsert({
|
||||
where: { id: notification.id },
|
||||
create: data,
|
||||
update: data,
|
||||
});
|
||||
|
||||
// 删除旧的关联
|
||||
await tx.notificationTagTarget.deleteMany({
|
||||
where: { notificationId: notification.id },
|
||||
});
|
||||
await tx.notificationUserTarget.deleteMany({
|
||||
where: { notificationId: notification.id },
|
||||
});
|
||||
|
||||
// 保存新的标签关联
|
||||
if (
|
||||
notification.targetType === TargetType.BY_TAG &&
|
||||
notification.targetConfig?.tagIds?.length
|
||||
) {
|
||||
await tx.notificationTagTarget.createMany({
|
||||
data: notification.targetConfig.tagIds.map((tagId) => ({
|
||||
notificationId: notification.id,
|
||||
tagId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 保存新的用户关联
|
||||
if (
|
||||
notification.targetType === TargetType.SPECIFIC &&
|
||||
notification.targetConfig?.accountSequences?.length
|
||||
) {
|
||||
await tx.notificationUserTarget.createMany({
|
||||
data: notification.targetConfig.accountSequences.map(
|
||||
(accountSequence) => ({
|
||||
notificationId: notification.id,
|
||||
accountSequence,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
return this.mapper.toDomain(saved);
|
||||
|
||||
// 重新加载完整数据
|
||||
const complete = await this.prisma.notification.findUnique({
|
||||
where: { id: saved.id },
|
||||
include: {
|
||||
targetTags: { select: { tagId: true } },
|
||||
targetUsers: { select: { accountSequence: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapper.toDomain(complete as NotificationWithTargets);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<NotificationEntity | null> {
|
||||
const notification = await this.prisma.notification.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
targetTags: { select: { tagId: true } },
|
||||
targetUsers: { select: { accountSequence: true } },
|
||||
},
|
||||
});
|
||||
return notification ? this.mapper.toDomain(notification) : null;
|
||||
return notification
|
||||
? this.mapper.toDomain(notification as NotificationWithTargets)
|
||||
: null;
|
||||
}
|
||||
|
||||
async findActiveNotifications(params?: {
|
||||
|
|
@ -47,11 +116,17 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
...(params?.type && { type: params.type }),
|
||||
},
|
||||
include: {
|
||||
targetTags: { select: { tagId: true } },
|
||||
targetUsers: { select: { accountSequence: true } },
|
||||
},
|
||||
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
||||
take: params?.limit ?? 50,
|
||||
skip: params?.offset ?? 0,
|
||||
});
|
||||
return notifications.map((n) => this.mapper.toDomain(n));
|
||||
return notifications.map((n) =>
|
||||
this.mapper.toDomain(n as NotificationWithTargets),
|
||||
);
|
||||
}
|
||||
|
||||
async findNotificationsForUser(params: {
|
||||
|
|
@ -61,18 +136,52 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
offset?: number;
|
||||
}): Promise<NotificationWithReadStatus[]> {
|
||||
const now = new Date();
|
||||
|
||||
// 获取用户的标签
|
||||
const userTagIds = await this.tagRepository.findUserTagIds(
|
||||
params.userSerialNum,
|
||||
);
|
||||
|
||||
// 查询通知
|
||||
const notifications = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
publishedAt: { lte: now },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
...(params.type && { type: params.type }),
|
||||
// 目标用户筛选
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
// ALL: 发给所有人
|
||||
{ targetType: 'ALL' },
|
||||
// BY_TAG: 用户必须有指定的标签
|
||||
{
|
||||
targetType: 'BY_TAG',
|
||||
targetTags: {
|
||||
some: {
|
||||
tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
// SPECIFIC: 指定给该用户
|
||||
{
|
||||
targetType: 'SPECIFIC',
|
||||
targetUsers: {
|
||||
some: { accountSequence: params.userSerialNum },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
readRecords: {
|
||||
where: { userSerialNum: params.userSerialNum },
|
||||
take: 1,
|
||||
},
|
||||
targetTags: { select: { tagId: true } },
|
||||
targetUsers: { select: { accountSequence: true } },
|
||||
},
|
||||
orderBy: [{ priority: 'desc' }, { publishedAt: 'desc' }],
|
||||
take: params.limit ?? 50,
|
||||
|
|
@ -80,7 +189,7 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
});
|
||||
|
||||
return notifications.map((n) => ({
|
||||
notification: this.mapper.toDomain(n),
|
||||
notification: this.mapper.toDomain(n as NotificationWithTargets),
|
||||
isRead: n.readRecords.length > 0,
|
||||
readAt: n.readRecords[0]?.readAt ?? null,
|
||||
}));
|
||||
|
|
@ -88,6 +197,10 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
|
||||
async countUnreadForUser(userSerialNum: string): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// 获取用户的标签
|
||||
const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum);
|
||||
|
||||
const count = await this.prisma.notification.count({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
|
|
@ -96,12 +209,37 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
readRecords: {
|
||||
none: { userSerialNum },
|
||||
},
|
||||
// 目标用户筛选
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ targetType: 'ALL' },
|
||||
{
|
||||
targetType: 'BY_TAG',
|
||||
targetTags: {
|
||||
some: {
|
||||
tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
targetType: 'SPECIFIC',
|
||||
targetUsers: {
|
||||
some: { accountSequence: userSerialNum },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string, userSerialNum: string): Promise<void> {
|
||||
async markAsRead(
|
||||
notificationId: string,
|
||||
userSerialNum: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.notificationRead.upsert({
|
||||
where: {
|
||||
notificationId_userSerialNum: {
|
||||
|
|
@ -119,6 +257,10 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
|
||||
async markAllAsRead(userSerialNum: string): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
// 获取用户的标签
|
||||
const userTagIds = await this.tagRepository.findUserTagIds(userSerialNum);
|
||||
|
||||
// 获取所有未读的有效通知
|
||||
const unreadNotifications = await this.prisma.notification.findMany({
|
||||
where: {
|
||||
|
|
@ -128,6 +270,27 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
readRecords: {
|
||||
none: { userSerialNum },
|
||||
},
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ targetType: 'ALL' },
|
||||
{
|
||||
targetType: 'BY_TAG',
|
||||
targetTags: {
|
||||
some: {
|
||||
tagId: { in: userTagIds.length > 0 ? userTagIds : ['__none__'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
targetType: 'SPECIFIC',
|
||||
targetUsers: {
|
||||
some: { accountSequence: userSerialNum },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
|
@ -161,11 +324,17 @@ export class NotificationRepositoryImpl implements NotificationRepository {
|
|||
...(params?.type && { type: params.type }),
|
||||
...(params?.isEnabled !== undefined && { isEnabled: params.isEnabled }),
|
||||
},
|
||||
include: {
|
||||
targetTags: { select: { tagId: true } },
|
||||
targetUsers: { select: { accountSequence: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: params?.limit ?? 50,
|
||||
skip: params?.offset ?? 0,
|
||||
});
|
||||
return notifications.map((n) => this.mapper.toDomain(n));
|
||||
return notifications.map((n) =>
|
||||
this.mapper.toDomain(n as NotificationWithTargets),
|
||||
);
|
||||
}
|
||||
|
||||
async count(params?: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// Tab 导航
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: white;
|
||||
color: $primary-color;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
background: $card-background;
|
||||
border-radius: 12px;
|
||||
|
|
@ -265,4 +297,67 @@
|
|||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 标签选择器
|
||||
&__tagSelector {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__tagOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
&__tagColor {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 用户ID输入
|
||||
&__userIdsInput {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
&__targetHint {
|
||||
font-size: 12px;
|
||||
color: $text-disabled;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal, toast, Button } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { UserTagsTab, AudienceSegmentsTab } from '@/components/features/notifications';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import {
|
||||
|
|
@ -15,8 +16,18 @@ import {
|
|||
NOTIFICATION_PRIORITY_OPTIONS,
|
||||
TARGET_TYPE_OPTIONS,
|
||||
} from '@/services/notificationService';
|
||||
import { userTagService, UserTag } from '@/services/userTagService';
|
||||
import styles from './notifications.module.scss';
|
||||
|
||||
// Tab 类型
|
||||
type TabType = 'notifications' | 'tags' | 'segments';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'notifications' as TabType, label: '通知列表' },
|
||||
{ key: 'tags' as TabType, label: '用户标签' },
|
||||
{ key: 'segments' as TabType, label: '人群包' },
|
||||
];
|
||||
|
||||
// 获取类型标签样式
|
||||
const getTypeStyle = (type: NotificationType) => {
|
||||
const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
|
|
@ -45,11 +56,18 @@ const getTargetLabel = (target: string) => {
|
|||
* 通知管理页面
|
||||
*/
|
||||
export default function NotificationsPage() {
|
||||
// Tab 状态
|
||||
const [activeTab, setActiveTab] = useState<TabType>('notifications');
|
||||
|
||||
// 通知数据状态
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<NotificationType | ''>('');
|
||||
|
||||
// 可用标签列表(用于定向选择)
|
||||
const [availableTags, setAvailableTags] = useState<UserTag[]>([]);
|
||||
|
||||
// 弹窗状态
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingNotification, setEditingNotification] = useState<NotificationItem | null>(null);
|
||||
|
|
@ -62,6 +80,8 @@ export default function NotificationsPage() {
|
|||
type: 'SYSTEM' as NotificationType,
|
||||
priority: 'NORMAL' as NotificationPriority,
|
||||
targetType: 'ALL' as TargetType,
|
||||
selectedTagIds: [] as string[],
|
||||
userIds: '',
|
||||
imageUrl: '',
|
||||
linkUrl: '',
|
||||
publishedAt: '',
|
||||
|
|
@ -85,9 +105,22 @@ export default function NotificationsPage() {
|
|||
}
|
||||
}, [typeFilter]);
|
||||
|
||||
// 加载可用标签
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const tags = await userTagService.getTags({ isAdvertisable: true, isEnabled: true });
|
||||
setAvailableTags(tags);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
}, [loadNotifications]);
|
||||
if (activeTab === 'notifications') {
|
||||
loadNotifications();
|
||||
loadTags();
|
||||
}
|
||||
}, [activeTab, loadNotifications, loadTags]);
|
||||
|
||||
// 打开创建弹窗
|
||||
const handleCreate = () => {
|
||||
|
|
@ -98,6 +131,8 @@ export default function NotificationsPage() {
|
|||
type: 'SYSTEM',
|
||||
priority: 'NORMAL',
|
||||
targetType: 'ALL',
|
||||
selectedTagIds: [],
|
||||
userIds: '',
|
||||
imageUrl: '',
|
||||
linkUrl: '',
|
||||
publishedAt: '',
|
||||
|
|
@ -115,6 +150,8 @@ export default function NotificationsPage() {
|
|||
type: notification.type,
|
||||
priority: notification.priority,
|
||||
targetType: notification.targetType,
|
||||
selectedTagIds: notification.targetConfig?.tagIds || [],
|
||||
userIds: notification.targetConfig?.accountSequences?.join('\n') || '',
|
||||
imageUrl: notification.imageUrl || '',
|
||||
linkUrl: notification.linkUrl || '',
|
||||
publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '',
|
||||
|
|
@ -140,6 +177,27 @@ export default function NotificationsPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// 构建目标配置
|
||||
let targetConfig: { tagIds?: string[]; accountSequences?: string[] } | undefined;
|
||||
|
||||
if (formData.targetType === 'BY_TAG') {
|
||||
if (formData.selectedTagIds.length === 0) {
|
||||
toast.error('请至少选择一个标签');
|
||||
return;
|
||||
}
|
||||
targetConfig = { tagIds: formData.selectedTagIds };
|
||||
} else if (formData.targetType === 'SPECIFIC') {
|
||||
const userIds = formData.userIds
|
||||
.split(/[\n,]/)
|
||||
.map(id => id.trim())
|
||||
.filter(Boolean);
|
||||
if (userIds.length === 0) {
|
||||
toast.error('请输入用户ID');
|
||||
return;
|
||||
}
|
||||
targetConfig = { accountSequences: userIds };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: formData.title.trim(),
|
||||
|
|
@ -147,6 +205,7 @@ export default function NotificationsPage() {
|
|||
type: formData.type,
|
||||
priority: formData.priority,
|
||||
targetType: formData.targetType,
|
||||
targetConfig,
|
||||
imageUrl: formData.imageUrl.trim() || undefined,
|
||||
linkUrl: formData.linkUrl.trim() || undefined,
|
||||
publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined,
|
||||
|
|
@ -168,6 +227,16 @@ export default function NotificationsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 切换标签选择
|
||||
const toggleTagSelection = (tagId: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedTagIds: prev.selectedTagIds.includes(tagId)
|
||||
? prev.selectedTagIds.filter(id => id !== tagId)
|
||||
: [...prev.selectedTagIds, tagId],
|
||||
}));
|
||||
};
|
||||
|
||||
// 切换启用状态
|
||||
const handleToggle = async (notification: NotificationItem) => {
|
||||
try {
|
||||
|
|
@ -194,118 +263,156 @@ export default function NotificationsPage() {
|
|||
return (
|
||||
<PageContainer title="通知管理">
|
||||
<div className={styles.notifications}>
|
||||
{/* 页面标题和操作按钮 */}
|
||||
{/* 页面标题 */}
|
||||
<div className={styles.notifications__header}>
|
||||
<h1 className={styles.notifications__title}>通知管理</h1>
|
||||
<div className={styles.notifications__actions}>
|
||||
<Button variant="primary" onClick={handleCreate}>
|
||||
+ 新建通知
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className={styles.notifications__title}>通知与用户画像</h1>
|
||||
</div>
|
||||
|
||||
{/* 主内容卡片 */}
|
||||
<div className={styles.notifications__card}>
|
||||
{/* 筛选区域 */}
|
||||
<div className={styles.notifications__filters}>
|
||||
<select
|
||||
className={styles.notifications__select}
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as NotificationType | '')}
|
||||
{/* Tab 导航 */}
|
||||
<div className={styles.notifications__tabs}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
styles.notifications__tab,
|
||||
activeTab === tab.key && styles['notifications__tab--active']
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 通知列表 */}
|
||||
<div className={styles.notifications__list}>
|
||||
{loading ? (
|
||||
<div className={styles.notifications__loading}>加载中...</div>
|
||||
) : error ? (
|
||||
<div className={styles.notifications__error}>
|
||||
<span>{error}</span>
|
||||
{/* Tab 内容 */}
|
||||
{activeTab === 'notifications' && (
|
||||
<>
|
||||
{/* 主内容卡片 */}
|
||||
<div className={styles.notifications__card}>
|
||||
{/* 筛选区域 */}
|
||||
<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>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button variant="primary" onClick={handleCreate}>
|
||||
+ 新建通知
|
||||
</Button>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className={styles.notifications__empty}>
|
||||
暂无通知,点击"新建通知"创建第一条通知
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
styles.notifications__item,
|
||||
!notification.isEnabled && styles['notifications__item--disabled']
|
||||
)}
|
||||
>
|
||||
<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)}
|
||||
</span>
|
||||
{!notification.isEnabled && (
|
||||
<span className={styles.notifications__disabled}>已禁用</span>
|
||||
|
||||
{/* 通知列表 */}
|
||||
<div className={styles.notifications__list}>
|
||||
{loading ? (
|
||||
<div className={styles.notifications__loading}>加载中...</div>
|
||||
) : error ? (
|
||||
<div className={styles.notifications__error}>
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={loadNotifications}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className={styles.notifications__empty}>
|
||||
暂无通知,点击"新建通知"创建第一条通知
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
styles.notifications__item,
|
||||
!notification.isEnabled && styles['notifications__item--disabled']
|
||||
)}
|
||||
>
|
||||
<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 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'tags' && (
|
||||
<div className={styles.notifications__card}>
|
||||
<UserTagsTab />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'segments' && (
|
||||
<div className={styles.notifications__card}>
|
||||
<AudienceSegmentsTab />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
|
|
@ -322,7 +429,7 @@ export default function NotificationsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={640}
|
||||
width={720}
|
||||
>
|
||||
<div className={styles.notifications__form}>
|
||||
<div className={styles.notifications__formGroup}>
|
||||
|
|
@ -390,6 +497,63 @@ export default function NotificationsPage() {
|
|||
</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__formGroup}>
|
||||
<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}`,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 TargetType = 'ALL' | 'NEW_USER' | 'VIP';
|
||||
export type TargetType = 'ALL' | 'BY_TAG' | 'SPECIFIC';
|
||||
|
||||
/** 目标配置 */
|
||||
export interface TargetConfig {
|
||||
type: TargetType;
|
||||
tagIds?: string[];
|
||||
accountSequences?: string[];
|
||||
}
|
||||
|
||||
/** 通知项 */
|
||||
export interface NotificationItem {
|
||||
|
|
@ -23,14 +30,13 @@ export interface NotificationItem {
|
|||
type: NotificationType;
|
||||
priority: NotificationPriority;
|
||||
targetType: TargetType;
|
||||
targetConfig: TargetConfig | null;
|
||||
imageUrl: string | null;
|
||||
linkUrl: string | null;
|
||||
isEnabled: boolean;
|
||||
publishedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/** 创建通知请求 */
|
||||
|
|
@ -40,6 +46,10 @@ export interface CreateNotificationRequest {
|
|||
type: NotificationType;
|
||||
priority?: NotificationPriority;
|
||||
targetType?: TargetType;
|
||||
targetConfig?: {
|
||||
tagIds?: string[];
|
||||
accountSequences?: string[];
|
||||
};
|
||||
imageUrl?: string;
|
||||
linkUrl?: string;
|
||||
publishedAt?: string;
|
||||
|
|
@ -53,6 +63,10 @@ export interface UpdateNotificationRequest {
|
|||
type?: NotificationType;
|
||||
priority?: NotificationPriority;
|
||||
targetType?: TargetType;
|
||||
targetConfig?: {
|
||||
tagIds?: string[];
|
||||
accountSequences?: string[];
|
||||
};
|
||||
imageUrl?: string | null;
|
||||
linkUrl?: string | null;
|
||||
isEnabled?: boolean;
|
||||
|
|
@ -86,9 +100,9 @@ export const NOTIFICATION_PRIORITY_OPTIONS = [
|
|||
|
||||
/** 目标用户类型选项 */
|
||||
export const TARGET_TYPE_OPTIONS = [
|
||||
{ value: 'ALL', label: '全部用户' },
|
||||
{ value: 'NEW_USER', label: '新用户' },
|
||||
{ value: 'VIP', label: 'VIP用户' },
|
||||
{ value: 'ALL', label: '全部用户', description: '发送给所有用户' },
|
||||
{ value: 'BY_TAG', label: '按标签筛选', description: '发送给有指定标签的用户' },
|
||||
{ value: 'SPECIFIC', label: '指定用户', description: '发送给特定用户列表' },
|
||||
] 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