644 lines
17 KiB
TypeScript
644 lines
17 KiB
TypeScript
/**
|
|
* 位置管理业务服务
|
|
*
|
|
* 功能描述:
|
|
* - 管理用户位置数据的业务逻辑
|
|
* - 处理位置验证、过滤和转换
|
|
* - 提供位置查询和统计功能
|
|
* - 实现位置相关的业务规则
|
|
*
|
|
* 职责分离:
|
|
* - 位置业务:专注于位置数据的业务逻辑处理
|
|
* - 数据验证:位置数据的格式验证和业务规则验证
|
|
* - 查询服务:提供灵活的位置数据查询接口
|
|
* - 统计分析:位置数据的统计和分析功能
|
|
*
|
|
* 技术实现:
|
|
* - 位置验证:多层次的位置数据验证机制
|
|
* - 性能优化:高效的位置查询和缓存策略
|
|
* - 数据转换:位置数据格式的标准化处理
|
|
* - 业务规则:复杂的位置相关业务逻辑实现
|
|
*
|
|
* 最近修改:
|
|
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
|
*
|
|
* @author moyin
|
|
* @version 1.2.0
|
|
* @since 2026-01-08
|
|
* @lastModified 2026-01-08
|
|
*/
|
|
|
|
import { Injectable, Inject, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { Position, PositionHistory } from '../../../core/location_broadcast_core/position.interface';
|
|
|
|
/**
|
|
* 位置查询请求DTO
|
|
*/
|
|
export interface PositionQueryRequest {
|
|
/** 用户ID列表 */
|
|
userIds?: string[];
|
|
/** 地图ID */
|
|
mapId?: string;
|
|
/** 会话ID */
|
|
sessionId?: string;
|
|
/** 查询范围(中心点和半径) */
|
|
range?: {
|
|
centerX: number;
|
|
centerY: number;
|
|
radius: number;
|
|
};
|
|
/** 时间范围 */
|
|
timeRange?: {
|
|
startTime: number;
|
|
endTime: number;
|
|
};
|
|
/** 是否包含离线用户 */
|
|
includeOffline?: boolean;
|
|
/** 分页参数 */
|
|
pagination?: {
|
|
offset: number;
|
|
limit: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 位置查询响应DTO
|
|
*/
|
|
export interface PositionQueryResponse {
|
|
/** 位置列表 */
|
|
positions: Position[];
|
|
/** 总数 */
|
|
total: number;
|
|
/** 查询时间戳 */
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* 位置统计请求DTO
|
|
*/
|
|
export interface PositionStatsRequest {
|
|
/** 地图ID */
|
|
mapId?: string;
|
|
/** 会话ID */
|
|
sessionId?: string;
|
|
/** 时间范围 */
|
|
timeRange?: {
|
|
startTime: number;
|
|
endTime: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 位置统计响应DTO
|
|
*/
|
|
export interface PositionStatsResponse {
|
|
/** 总用户数 */
|
|
totalUsers: number;
|
|
/** 在线用户数 */
|
|
onlineUsers: number;
|
|
/** 活跃地图数 */
|
|
activeMaps: number;
|
|
/** 地图用户分布 */
|
|
mapDistribution: Record<string, number>;
|
|
/** 位置更新频率(每分钟) */
|
|
updateFrequency: number;
|
|
/** 统计时间戳 */
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* 位置历史查询请求DTO
|
|
*/
|
|
export interface PositionHistoryRequest {
|
|
/** 用户ID */
|
|
userId: string;
|
|
/** 时间范围 */
|
|
timeRange?: {
|
|
startTime: number;
|
|
endTime: number;
|
|
};
|
|
/** 地图ID过滤 */
|
|
mapId?: string;
|
|
/** 最大记录数 */
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* 位置验证结果DTO
|
|
*/
|
|
export interface PositionValidationResult {
|
|
/** 是否有效 */
|
|
isValid: boolean;
|
|
/** 错误信息 */
|
|
errors: string[];
|
|
/** 警告信息 */
|
|
warnings: string[];
|
|
/** 修正后的位置(如果有) */
|
|
correctedPosition?: Position;
|
|
}
|
|
|
|
@Injectable()
|
|
export class LocationPositionService {
|
|
private readonly logger = new Logger(LocationPositionService.name);
|
|
|
|
/** 坐标最大值 */
|
|
/** 坐标最大值 */
|
|
private static readonly MAX_COORDINATE = 999999;
|
|
/** 坐标最小值 */
|
|
private static readonly MIN_COORDINATE = -999999;
|
|
/** 默认查询限制 */
|
|
private static readonly DEFAULT_QUERY_LIMIT = 100;
|
|
/** 时间转换常量 */
|
|
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
|
/** 位置时间戳最大偏差(毫秒) */
|
|
private static readonly MAX_TIMESTAMP_DIFF = 5 * LocationPositionService.MILLISECONDS_PER_MINUTE;
|
|
/** 地图ID最大长度 */
|
|
private static readonly MAX_MAP_ID_LENGTH = 50;
|
|
/** 用户ID列表最大数量 */
|
|
private static readonly MAX_USER_IDS_COUNT = 1000;
|
|
/** 查询半径最大值 */
|
|
private static readonly MAX_QUERY_RADIUS = 10000;
|
|
/** 分页限制最大值 */
|
|
private static readonly MAX_PAGINATION_LIMIT = 1000;
|
|
|
|
constructor(
|
|
@Inject('ILocationBroadcastCore')
|
|
private readonly locationBroadcastCore: any,
|
|
@Inject('IUserPositionCore')
|
|
private readonly userPositionCore: any,
|
|
) {}
|
|
|
|
/**
|
|
* 查询位置信息
|
|
*
|
|
* 业务逻辑:
|
|
* 1. 验证查询参数
|
|
* 2. 根据条件构建查询策略
|
|
* 3. 执行位置数据查询
|
|
* 4. 过滤和排序结果
|
|
* 5. 返回格式化的查询结果
|
|
*
|
|
* @param request 位置查询请求
|
|
* @returns 位置查询响应
|
|
*/
|
|
async queryPositions(request: PositionQueryRequest): Promise<PositionQueryResponse> {
|
|
const startTime = Date.now();
|
|
|
|
this.logger.log('查询位置信息', {
|
|
operation: 'queryPositions',
|
|
userIds: request.userIds?.length,
|
|
mapId: request.mapId,
|
|
sessionId: request.sessionId,
|
|
hasRange: !!request.range,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
try {
|
|
// 1. 验证查询参数
|
|
this.validatePositionQuery(request);
|
|
|
|
let positions: Position[] = [];
|
|
|
|
// 2. 根据查询条件执行不同的查询策略
|
|
if (request.sessionId) {
|
|
// 按会话查询
|
|
positions = await this.locationBroadcastCore.getSessionPositions(request.sessionId);
|
|
} else if (request.mapId) {
|
|
// 按地图查询
|
|
positions = await this.locationBroadcastCore.getMapPositions(request.mapId);
|
|
} else if (request.userIds && request.userIds.length > 0) {
|
|
// 按用户ID列表查询
|
|
positions = await this.queryPositionsByUserIds(request.userIds);
|
|
} else {
|
|
// 全量查询(需要谨慎使用)
|
|
this.logger.warn('执行全量位置查询', { request });
|
|
positions = [];
|
|
}
|
|
|
|
// 3. 应用过滤条件
|
|
positions = this.applyPositionFilters(positions, request);
|
|
|
|
// 4. 应用分页
|
|
const total = positions.length;
|
|
if (request.pagination) {
|
|
const { offset, limit } = request.pagination;
|
|
positions = positions.slice(offset, offset + limit);
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
this.logger.log('位置查询完成', {
|
|
operation: 'queryPositions',
|
|
resultCount: positions.length,
|
|
total,
|
|
duration,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
return {
|
|
positions,
|
|
total,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
|
|
this.logger.error('位置查询失败', {
|
|
operation: 'queryPositions',
|
|
request,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
duration,
|
|
timestamp: new Date().toISOString()
|
|
}, error instanceof Error ? error.stack : undefined);
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取位置统计信息
|
|
*
|
|
* @param request 统计请求
|
|
* @returns 统计结果
|
|
*/
|
|
async getPositionStats(request: PositionStatsRequest): Promise<PositionStatsResponse> {
|
|
try {
|
|
let positions: Position[] = [];
|
|
|
|
// 根据条件获取位置数据
|
|
if (request.sessionId) {
|
|
positions = await this.locationBroadcastCore.getSessionPositions(request.sessionId);
|
|
} else if (request.mapId) {
|
|
positions = await this.locationBroadcastCore.getMapPositions(request.mapId);
|
|
}
|
|
|
|
// 应用时间过滤
|
|
if (request.timeRange) {
|
|
positions = positions.filter(pos =>
|
|
pos.timestamp >= request.timeRange!.startTime &&
|
|
pos.timestamp <= request.timeRange!.endTime
|
|
);
|
|
}
|
|
|
|
// 计算统计信息
|
|
const totalUsers = positions.length;
|
|
const onlineUsers = totalUsers; // 缓存中的都是在线用户
|
|
|
|
// 统计地图分布
|
|
const mapDistribution: Record<string, number> = {};
|
|
positions.forEach(pos => {
|
|
mapDistribution[pos.mapId] = (mapDistribution[pos.mapId] || 0) + 1;
|
|
});
|
|
|
|
const activeMaps = Object.keys(mapDistribution).length;
|
|
|
|
// 计算更新频率(简化计算)
|
|
const updateFrequency = positions.length > 0 ?
|
|
positions.length / Math.max(1, (Date.now() - Math.min(...positions.map(p => p.timestamp))) / LocationPositionService.MILLISECONDS_PER_MINUTE) : 0;
|
|
|
|
return {
|
|
totalUsers,
|
|
onlineUsers,
|
|
activeMaps,
|
|
mapDistribution,
|
|
updateFrequency,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
} catch (error) {
|
|
this.logger.error('获取位置统计失败', {
|
|
operation: 'getPositionStats',
|
|
request,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取用户位置历史
|
|
*
|
|
* @param request 历史查询请求
|
|
* @returns 位置历史列表
|
|
*/
|
|
async getPositionHistory(request: PositionHistoryRequest): Promise<PositionHistory[]> {
|
|
try {
|
|
this.logger.log('查询用户位置历史', {
|
|
operation: 'getPositionHistory',
|
|
userId: request.userId,
|
|
mapId: request.mapId,
|
|
limit: request.limit,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// 从核心服务获取位置历史
|
|
const history = await this.userPositionCore.getPositionHistory(
|
|
request.userId,
|
|
request.limit || LocationPositionService.DEFAULT_QUERY_LIMIT
|
|
);
|
|
|
|
// 应用过滤条件
|
|
let filteredHistory = history;
|
|
|
|
if (request.timeRange) {
|
|
filteredHistory = filteredHistory.filter(h =>
|
|
h.timestamp >= request.timeRange!.startTime &&
|
|
h.timestamp <= request.timeRange!.endTime
|
|
);
|
|
}
|
|
|
|
if (request.mapId) {
|
|
filteredHistory = filteredHistory.filter(h => h.mapId === request.mapId);
|
|
}
|
|
|
|
return filteredHistory;
|
|
|
|
} catch (error) {
|
|
this.logger.error('获取位置历史失败', {
|
|
operation: 'getPositionHistory',
|
|
request,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 验证位置数据
|
|
*
|
|
* @param position 位置数据
|
|
* @returns 验证结果
|
|
*/
|
|
async validatePosition(position: Position): Promise<PositionValidationResult> {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
try {
|
|
// 1. 基础数据验证
|
|
if (!position.userId) {
|
|
errors.push('用户ID不能为空');
|
|
}
|
|
|
|
if (!position.mapId) {
|
|
errors.push('地图ID不能为空');
|
|
}
|
|
|
|
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
|
|
errors.push('坐标必须是数字');
|
|
}
|
|
|
|
if (!isFinite(position.x) || !isFinite(position.y)) {
|
|
errors.push('坐标必须是有效的数字');
|
|
}
|
|
|
|
// 2. 坐标范围验证
|
|
if (position.x > LocationPositionService.MAX_COORDINATE || position.x < LocationPositionService.MIN_COORDINATE ||
|
|
position.y > LocationPositionService.MAX_COORDINATE || position.y < LocationPositionService.MIN_COORDINATE) {
|
|
errors.push('坐标超出允许范围');
|
|
}
|
|
|
|
// 3. 时间戳验证
|
|
if (position.timestamp) {
|
|
const now = Date.now();
|
|
const timeDiff = Math.abs(now - position.timestamp);
|
|
|
|
if (timeDiff > LocationPositionService.MAX_TIMESTAMP_DIFF) {
|
|
warnings.push('位置时间戳与当前时间差异较大');
|
|
}
|
|
}
|
|
|
|
// 4. 地图ID格式验证
|
|
if (position.mapId && position.mapId.length > 50) {
|
|
errors.push('地图ID长度不能超过50个字符');
|
|
}
|
|
|
|
// 5. 元数据验证
|
|
if (position.metadata) {
|
|
try {
|
|
JSON.stringify(position.metadata);
|
|
} catch {
|
|
errors.push('位置元数据格式无效');
|
|
}
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings
|
|
};
|
|
|
|
} catch (error) {
|
|
this.logger.error('位置验证失败', {
|
|
operation: 'validatePosition',
|
|
position,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
return {
|
|
isValid: false,
|
|
errors: ['位置验证过程中发生错误'],
|
|
warnings
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 计算两个位置之间的距离
|
|
*
|
|
* @param pos1 位置1
|
|
* @param pos2 位置2
|
|
* @returns 距离(像素单位)
|
|
*/
|
|
calculateDistance(pos1: Position, pos2: Position): number {
|
|
if (pos1.mapId !== pos2.mapId) {
|
|
return Infinity; // 不同地图距离为无穷大
|
|
}
|
|
|
|
const dx = pos1.x - pos2.x;
|
|
const dy = pos1.y - pos2.y;
|
|
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/**
|
|
* 获取指定范围内的用户
|
|
*
|
|
* @param centerPosition 中心位置
|
|
* @param radius 半径
|
|
* @returns 范围内的位置列表
|
|
*/
|
|
async getUsersInRange(centerPosition: Position, radius: number): Promise<Position[]> {
|
|
try {
|
|
// 获取同地图的所有用户
|
|
const mapPositions = await this.locationBroadcastCore.getMapPositions(centerPosition.mapId);
|
|
|
|
// 过滤范围内的用户
|
|
return mapPositions.filter(pos => {
|
|
if (pos.userId === centerPosition.userId) {
|
|
return false; // 排除自己
|
|
}
|
|
|
|
const distance = this.calculateDistance(centerPosition, pos);
|
|
return distance <= radius;
|
|
});
|
|
|
|
} catch (error) {
|
|
this.logger.error('获取范围内用户失败', {
|
|
operation: 'getUsersInRange',
|
|
centerPosition,
|
|
radius,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 批量更新用户位置
|
|
*
|
|
* @param positions 位置列表
|
|
* @returns 更新结果
|
|
*/
|
|
async batchUpdatePositions(positions: Position[]): Promise<{ success: number; failed: number }> {
|
|
let success = 0;
|
|
let failed = 0;
|
|
|
|
for (const position of positions) {
|
|
try {
|
|
// 验证位置
|
|
const validation = await this.validatePosition(position);
|
|
if (!validation.isValid) {
|
|
failed++;
|
|
continue;
|
|
}
|
|
|
|
// 更新位置
|
|
await this.locationBroadcastCore.setUserPosition(position.userId, position);
|
|
success++;
|
|
|
|
} catch (error) {
|
|
this.logger.warn('批量更新位置失败', {
|
|
userId: position.userId,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
this.logger.log('批量位置更新完成', {
|
|
operation: 'batchUpdatePositions',
|
|
total: positions.length,
|
|
success,
|
|
failed
|
|
});
|
|
|
|
return { success, failed };
|
|
}
|
|
|
|
/**
|
|
* 根据用户ID列表查询位置
|
|
*
|
|
* @param userIds 用户ID列表
|
|
* @returns 位置列表
|
|
* @private
|
|
*/
|
|
private async queryPositionsByUserIds(userIds: string[]): Promise<Position[]> {
|
|
const positions: Position[] = [];
|
|
|
|
for (const userId of userIds) {
|
|
try {
|
|
const position = await this.locationBroadcastCore.getUserPosition(userId);
|
|
if (position) {
|
|
positions.push(position);
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn('获取用户位置失败', {
|
|
userId,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
return positions;
|
|
}
|
|
|
|
/**
|
|
* 应用位置过滤条件
|
|
*
|
|
* @param positions 原始位置列表
|
|
* @param request 查询请求
|
|
* @returns 过滤后的位置列表
|
|
* @private
|
|
*/
|
|
private applyPositionFilters(positions: Position[], request: PositionQueryRequest): Position[] {
|
|
let filtered = positions;
|
|
|
|
// 时间范围过滤
|
|
if (request.timeRange) {
|
|
filtered = filtered.filter(pos =>
|
|
pos.timestamp >= request.timeRange!.startTime &&
|
|
pos.timestamp <= request.timeRange!.endTime
|
|
);
|
|
}
|
|
|
|
// 地图过滤
|
|
if (request.mapId) {
|
|
filtered = filtered.filter(pos => pos.mapId === request.mapId);
|
|
}
|
|
|
|
// 用户ID过滤
|
|
if (request.userIds && request.userIds.length > 0) {
|
|
const userIdSet = new Set(request.userIds);
|
|
filtered = filtered.filter(pos => userIdSet.has(pos.userId));
|
|
}
|
|
|
|
// 范围过滤
|
|
if (request.range) {
|
|
const { centerX, centerY, radius } = request.range;
|
|
filtered = filtered.filter(pos => {
|
|
const distance = Math.sqrt(
|
|
Math.pow(pos.x - centerX, 2) + Math.pow(pos.y - centerY, 2)
|
|
);
|
|
return distance <= radius;
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
/**
|
|
* 验证位置查询参数
|
|
*
|
|
* @param request 查询请求
|
|
* @private
|
|
*/
|
|
private validatePositionQuery(request: PositionQueryRequest): void {
|
|
if (request.userIds && request.userIds.length > 1000) {
|
|
throw new BadRequestException('用户ID列表不能超过1000个');
|
|
}
|
|
|
|
if (request.range) {
|
|
const { centerX, centerY, radius } = request.range;
|
|
|
|
if (typeof centerX !== 'number' || typeof centerY !== 'number' || typeof radius !== 'number') {
|
|
throw new BadRequestException('范围查询参数必须是数字');
|
|
}
|
|
|
|
if (radius < 0 || radius > 10000) {
|
|
throw new BadRequestException('查询半径必须在0-10000之间');
|
|
}
|
|
}
|
|
|
|
if (request.pagination) {
|
|
const { offset, limit } = request.pagination;
|
|
|
|
if (offset < 0 || limit < 1 || limit > 1000) {
|
|
throw new BadRequestException('分页参数无效');
|
|
}
|
|
}
|
|
}
|
|
} |