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:
hailin 2025-12-24 16:19:05 -08:00
parent 934323e4c6
commit b5e45c4532
46 changed files with 8803 additions and 158 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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")
}
// =============================================================================

View File

@ -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,
})),
};
}
}

View File

@ -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,
};
}
}

View File

@ -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);

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}>;
}

View File

@ -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[];
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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 {}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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)];
}
}

View File

@ -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;
}
}

View File

@ -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', // 特定用户列表
}

View File

@ -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);
}
}

View File

@ -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(),
);
}
}

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 };
}
}

View File

@ -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,

View File

@ -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,
);
}
}

View File

@ -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,
);
}
}

View File

@ -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?: {

View File

@ -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,
);
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
export { AudienceSegmentsTab } from './AudienceSegmentsTab';
export { default } from './AudienceSegmentsTab';

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
export { UserTagsTab } from './UserTagsTab';
export { default } from './UserTagsTab';

View File

@ -0,0 +1,2 @@
export { UserTagsTab } from './UserTagsTab';
export { AudienceSegmentsTab } from './AudienceSegmentsTab';

View File

@ -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;

View File

@ -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;

View File

@ -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;
/**

View File

@ -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;